From 4762391e95b6e373c8924a809c604a27e4aa5dd9 Mon Sep 17 00:00:00 2001 From: Mattias Granlund Date: Sun, 12 Jul 2026 11:30:04 +0300 Subject: [PATCH 01/11] Drop segment indices from debug and snapshot output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Segment indices are per-build allocation ordinals โ€” they say nothing about the graph and reshuffle on any structural change, so every snapshot that carries them re-records as pure noise. The graph work that follows would otherwise renumber more or less every snapshot in the workspace. - the debug/snapshot renderers stop printing segment ids - affected snapshots re-record once (23 files, otherwise unchanged) After this, structural commits diff on substance. --- crates/but-api/tests/api/branch_checkout.rs | 26 +- crates/but-graph/src/projection/stack.rs | 12 +- .../src/projection/workspace/api/mod.rs | 3 +- .../but-graph/src/projection/workspace/mod.rs | 23 +- crates/but-graph/tests/graph/init/mod.rs | 198 +- .../tests/graph/init/with_workspace.rs | 1662 ++++++++--------- .../rebase/graph_rebase/rebase_identities.rs | 6 +- crates/but-workspace/src/ref_info.rs | 4 +- .../tests/workspace/branch/apply_unapply.rs | 1054 +++++------ .../workspace/branch/create_reference.rs | 834 ++++----- .../tests/workspace/branch/move_branch.rs | 276 +-- .../workspace/branch/remove_reference.rs | 114 +- .../tests/workspace/branch/tear_off_branch.rs | 148 +- .../tests/workspace/commit/discard_commit.rs | 48 +- .../tests/workspace/commit/move_commit.rs | 220 +-- .../tests/workspace/commit/squash_commits.rs | 40 +- .../tests/workspace/ref_info/mod.rs | 9 - .../journey/exhaustive_with_squash_merges.rs | 40 - .../journey/integrate_with_merges.rs | 24 - .../journey/integrate_with_rebase.rs | 12 - .../ref_info/with_workspace_commit/legacy.rs | 24 +- .../ref_info/with_workspace_commit/mod.rs | 156 -- .../tests/workspace/upstream_integration.rs | 238 +-- 23 files changed, 2469 insertions(+), 2702 deletions(-) diff --git a/crates/but-api/tests/api/branch_checkout.rs b/crates/but-api/tests/api/branch_checkout.rs index 80b78126e5d..2e060716ace 100644 --- a/crates/but-api/tests/api/branch_checkout.rs +++ b/crates/but-api/tests/api/branch_checkout.rs @@ -111,13 +111,13 @@ fn checkout_returns_head_info_matching_fresh_head_info() -> anyhow::Result<()> { snapbox::assert_data_eq!( crate::support::workspace_graph(&ctx)?, - snapbox::str![[r" -โŒ‚:0:feature[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 5374caf -โ””โ”€โ”€ โ‰ก:0:feature[๐ŸŒณ] on 5374caf {1} - โ””โ”€โ”€ :0:feature[๐ŸŒณ] + snapbox::str![[r#" +โŒ‚:feature[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 5374caf +โ””โ”€โ”€ โ‰ก:feature[๐ŸŒณ] on 5374caf {1} + โ””โ”€โ”€ :feature[๐ŸŒณ] โ””โ”€โ”€ ยทedd8381 -"]] +"#]] ); #[cfg(feature = "graph-workspace")] @@ -172,7 +172,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "โ–บfeature[๐ŸŒณ]", remote_tracking_ref_name: "None", commits: [ @@ -192,14 +191,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(2), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(5374caf21933aee76b72bad8d6e30949c7a30e04), - segment_index: NodeIndex(1), }, ), lower_bound: Some( @@ -252,12 +249,12 @@ fn checkout_new_returns_head_info_matching_fresh_head_info() -> anyhow::Result<( snapbox::assert_data_eq!( crate::support::workspace_graph(&ctx)?, - snapbox::str![[r" -โŒ‚:0:new-branch[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 5374caf -โ””โ”€โ”€ โ‰ก:0:new-branch[๐ŸŒณ] {1} - โ””โ”€โ”€ :0:new-branch[๐ŸŒณ] + snapbox::str![[r#" +โŒ‚:new-branch[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 5374caf +โ””โ”€โ”€ โ‰ก:new-branch[๐ŸŒณ] {1} + โ””โ”€โ”€ :new-branch[๐ŸŒณ] -"]] +"#]] ); #[cfg(feature = "graph-workspace")] @@ -310,7 +307,6 @@ RefInfo { base: None, segments: [ ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "โ–บnew-branch[๐ŸŒณ]", remote_tracking_ref_name: "None", commits: [], @@ -328,14 +324,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(5374caf21933aee76b72bad8d6e30949c7a30e04), - segment_index: NodeIndex(0), }, ), lower_bound: Some( diff --git a/crates/but-graph/src/projection/stack.rs b/crates/but-graph/src/projection/stack.rs index 975aaef5fad..a3069ef3588 100644 --- a/crates/but-graph/src/projection/stack.rs +++ b/crates/but-graph/src/projection/stack.rs @@ -412,11 +412,12 @@ impl StackSegment { /// Digest as much as possible into a single line. pub fn debug_string(&self) -> String { + // Segment indices are ephemeral; keep them out of snapshot output. self.debug_string_with_ref_name_remote(Graph::ref_and_remote_debug_string( self.ref_info.as_ref(), self.remote_tracking_ref_name.as_ref(), - self.sibling_segment_id, - self.remote_tracking_branch_segment_id, + None, + None, )) } @@ -426,8 +427,8 @@ impl StackSegment { graph.ref_and_remote_debug_string_with_graph_context( self.ref_info.as_ref(), self.remote_tracking_ref_name.as_ref(), - self.sibling_segment_id, - self.remote_tracking_branch_segment_id, + None, + None, ), ) } @@ -446,10 +447,9 @@ impl StackSegment { 0 }; format!( - "{ep}{meta}:{id}:{ref_name_remote}{local_commits}{remote_commits}", + "{ep}{meta}:{ref_name_remote}{local_commits}{remote_commits}", ep = if self.is_entrypoint { "๐Ÿ‘‰" } else { "" }, meta = if self.metadata.is_some() { "๐Ÿ“™" } else { "" }, - id = self.id.index(), local_commits = if num_local_commits == 0 { "".into() } else { diff --git a/crates/but-graph/src/projection/workspace/api/mod.rs b/crates/but-graph/src/projection/workspace/api/mod.rs index 9f421e328ae..3c560beebdf 100644 --- a/crates/but-graph/src/projection/workspace/api/mod.rs +++ b/crates/but-graph/src/projection/workspace/api/mod.rs @@ -412,9 +412,8 @@ impl Workspace { }, ); format!( - "{meta}{sign}:{id}:{name} <> โœ“{target}{bound}", + "{meta}{sign}:{name} <> โœ“{target}{bound}", meta = if self.metadata.is_some() { "๐Ÿ“•" } else { "" }, - id = self.id.index(), bound = self .lower_bound .map(|base| format!(" on {}", base.to_hex_with_len(7))) diff --git a/crates/but-graph/src/projection/workspace/mod.rs b/crates/but-graph/src/projection/workspace/mod.rs index 975f56ec53a..bec89dc9ecb 100644 --- a/crates/but-graph/src/projection/workspace/mod.rs +++ b/crates/but-graph/src/projection/workspace/mod.rs @@ -224,7 +224,7 @@ impl WorkspaceKind { /// Information about the target reference, which marks a portion in the commit-graph /// that the workspace wants to integrate with. -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct TargetRef { /// The name of the target branch, i.e. the branch that all [Stacks](Stack) want to get merged into. /// Typically, this is `refs/remotes/origin/main`. @@ -235,13 +235,23 @@ pub struct TargetRef { pub commits_ahead: usize, } +/// Segment indices are ephemeral; keep them out of snapshot output. +impl std::fmt::Debug for TargetRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TargetRef") + .field("ref_name", &self.ref_name) + .field("commits_ahead", &self.commits_ahead) + .finish() + } +} + /// Information about the target commit, which marks a portion in the commit-graph /// that the workspace wants to integrate with. /// /// It's an unnamed point of interest which may /// be set by any means. Typically, it's set by using a stored value, which makes it /// a point in time at which we have seen the [`TargetRef`]. -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct TargetCommit { /// The hash of the commit that was once included in the [target ref](TargetRef), and that we remember to expand /// the reach of the workspace. @@ -250,6 +260,15 @@ pub struct TargetCommit { pub segment_index: SegmentIndex, } +/// Segment indices are ephemeral; keep them out of snapshot output. +impl std::fmt::Debug for TargetCommit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TargetCommit") + .field("commit_id", &self.commit_id) + .finish() + } +} + impl TargetCommit { /// Find `target_commit_id` in the `graph` and store its segment in this instance, or return `None` if not found. /// The `None` case is acceptable as we consider these possibly stored. diff --git a/crates/but-graph/tests/graph/init/mod.rs b/crates/but-graph/tests/graph/init/mod.rs index 97fe3bf4b27..c69060430c2 100644 --- a/crates/but-graph/tests/graph/init/mod.rs +++ b/crates/but-graph/tests/graph/init/mod.rs @@ -87,9 +87,9 @@ Graph { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ] +โŒ‚:main[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] {1} + โ””โ”€โ”€ :main[๐ŸŒณ] "#]] ); @@ -248,11 +248,11 @@ Graph { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:DETACHED <> โœ“! -โ””โ”€โ”€ โ‰ก:0:anon: {1} - โ”œโ”€โ”€ :0:anon: +โŒ‚:DETACHED <> โœ“! +โ””โ”€โ”€ โ‰ก:anon: {1} + โ”œโ”€โ”€ :anon: โ”‚ โ””โ”€โ”€ ยท541396b โ–บtags/annotated, โ–บtags/release/v1, โ–บmain - โ””โ”€โ”€ :1:other + โ””โ”€โ”€ :other โ””โ”€โ”€ ยทfafd9d0 "#]] @@ -337,9 +337,9 @@ fn shallow_clone_stops_at_shallow_boundary() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 71a64f3 -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] <> origin/main โ†’:1: {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ] <> origin/main โ†’:1: +โŒ‚:main[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 71a64f3 +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] <> origin/main {1} + โ””โ”€โ”€ :main[๐ŸŒณ] <> origin/main "#]] ); @@ -402,13 +402,13 @@ fn merge_first_parent_older_non_workspace_maintains_graph_order() -> anyhow::Res snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:first-parent[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:first-parent[๐ŸŒณ] {1} - โ”œโ”€โ”€ :0:first-parent[๐ŸŒณ] +โŒ‚:first-parent[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:first-parent[๐ŸŒณ] {1} + โ”œโ”€โ”€ :first-parent[๐ŸŒณ] โ”‚ โ”œโ”€โ”€ ยท738ea18 โ”‚ โ”œโ”€โ”€ ยท408ca26 โ”‚ โ””โ”€โ”€ ยท2854fa2 - โ””โ”€โ”€ :4:main + โ””โ”€โ”€ :main โ””โ”€โ”€ ยท793a434 โ–บtags/base "#]] @@ -456,9 +456,9 @@ fn main_advanced_remote_advanced() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on ce09734 -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] <> origin/main โ†’:1:โ‡ก1โ‡ฃ1 on ce09734 {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ] <> origin/main โ†’:1:โ‡ก1โ‡ฃ1 +โŒ‚:main[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on ce09734 +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] <> origin/mainโ‡ก1โ‡ฃ1 on ce09734 {1} + โ””โ”€โ”€ :main[๐ŸŒณ] <> origin/mainโ‡ก1โ‡ฃ1 โ”œโ”€โ”€ ๐ŸŸฃ5d29d62 โ””โ”€โ”€ ยท971953d @@ -511,9 +511,9 @@ fn only_remote_advanced() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ2 on 971953d -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] <> origin/main โ†’:1:โ‡ฃ1 {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ] <> origin/main โ†’:1:โ‡ฃ1 +โŒ‚:main[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ2 on 971953d +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] <> origin/mainโ‡ฃ1 {1} + โ””โ”€โ”€ :main[๐ŸŒณ] <> origin/mainโ‡ฃ1 โ””โ”€โ”€ ๐ŸŸฃ085535d "#]] @@ -567,9 +567,9 @@ fn only_remote_advanced_with_special_branch_name() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ2 on 971953d -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] <> origin/main โ†’:1:โ‡ฃ1 {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ] <> origin/main โ†’:1:โ‡ฃ1 +โŒ‚:main[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ2 on 971953d +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] <> origin/mainโ‡ฃ1 {1} + โ””โ”€โ”€ :main[๐ŸŒณ] <> origin/mainโ‡ฃ1 โ””โ”€โ”€ ๐ŸŸฃ085535d "#]] @@ -639,9 +639,9 @@ fn multi_root() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ] +โŒ‚:main[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] {1} + โ””โ”€โ”€ :main[๐ŸŒณ] โ”œโ”€โ”€ ยทc6c8c05 โ”œโ”€โ”€ ยท76fc5c4 โ””โ”€โ”€ ยทe5d0542 @@ -724,14 +724,14 @@ fn four_diamond() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:merged[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:merged[๐ŸŒณ] {1} - โ”œโ”€โ”€ :0:merged[๐ŸŒณ] +โŒ‚:merged[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:merged[๐ŸŒณ] {1} + โ”œโ”€โ”€ :merged[๐ŸŒณ] โ”‚ โ””โ”€โ”€ ยท8a6c109 - โ”œโ”€โ”€ :1:A + โ”œโ”€โ”€ :A โ”‚ โ”œโ”€โ”€ ยท62b409a โ”‚ โ””โ”€โ”€ ยท592abec - โ””โ”€โ”€ :7:main + โ””โ”€โ”€ :main โ””โ”€โ”€ ยท965998b "#]] @@ -1073,11 +1073,11 @@ fn explicit_traversal_tips_use_integrated_tip_as_workspace_target_commit() -> an snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:2:merged[๐ŸŒณ] <> โœ“refs/heads/Aโ‡ฃ3 on 965998b -โ””โ”€โ”€ โ‰ก:2:merged[๐ŸŒณ] on 965998b {1} - โ”œโ”€โ”€ :2:merged[๐ŸŒณ] +โŒ‚:merged[๐ŸŒณ] <> โœ“refs/heads/Aโ‡ฃ3 on 965998b +โ””โ”€โ”€ โ‰ก:merged[๐ŸŒณ] on 965998b {1} + โ”œโ”€โ”€ :merged[๐ŸŒณ] โ”‚ โ””โ”€โ”€ ยท8a6c109 - โ””โ”€โ”€ :0:A + โ””โ”€โ”€ :A โ”œโ”€โ”€ ยท62b409a (โœ“) โ””โ”€โ”€ ยท592abec (โœ“) @@ -1145,12 +1145,12 @@ fn stacked_rebased_remotes() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:B[๐ŸŒณ] <> โœ“refs/remotes/origin/Bโ‡ฃ2 on fafd9d0 -โ””โ”€โ”€ โ‰ก:0:B[๐ŸŒณ] <> origin/B โ†’:1:โ‡ก1โ‡ฃ1 on fafd9d0 {1} - โ”œโ”€โ”€ :0:B[๐ŸŒณ] <> origin/B โ†’:1:โ‡ก1โ‡ฃ1 +โŒ‚:B[๐ŸŒณ] <> โœ“refs/remotes/origin/Bโ‡ฃ2 on fafd9d0 +โ””โ”€โ”€ โ‰ก:B[๐ŸŒณ] <> origin/Bโ‡ก1โ‡ฃ1 on fafd9d0 {1} + โ”œโ”€โ”€ :B[๐ŸŒณ] <> origin/Bโ‡ก1โ‡ฃ1 โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ682be32 โ”‚ โ””โ”€โ”€ ยท312f819 - โ””โ”€โ”€ :2:A <> origin/A โ†’:3:โ‡ก1โ‡ฃ1 + โ””โ”€โ”€ :A <> origin/Aโ‡ก1โ‡ฃ1 โ”œโ”€โ”€ ๐ŸŸฃe29c23d โ””โ”€โ”€ ยทe255adc @@ -1191,9 +1191,9 @@ fn stacked_rebased_remotes() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:B[๐ŸŒณ] <> โœ“refs/remotes/origin/Bโ‡ฃ1 on 312f819 -โ””โ”€โ”€ โ‰ก:0:B[๐ŸŒณ] <> origin/B โ†’:1:โ‡ฃ1 on e255adc {1} - โ””โ”€โ”€ :0:B[๐ŸŒณ] <> origin/B โ†’:1:โ‡ฃ1 +โŒ‚:B[๐ŸŒณ] <> โœ“refs/remotes/origin/Bโ‡ฃ1 on 312f819 +โ””โ”€โ”€ โ‰ก:B[๐ŸŒณ] <> origin/Bโ‡ฃ1 on e255adc {1} + โ””โ”€โ”€ :B[๐ŸŒณ] <> origin/Bโ‡ฃ1 โ””โ”€โ”€ ๐ŸŸฃ682be32 "#]] @@ -1253,9 +1253,9 @@ fn stacked_rebased_remotes() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:A <> โœ“refs/remotes/origin/Aโ‡ฃ1 on fafd9d0 -โ””โ”€โ”€ โ‰ก:0:A <> origin/A โ†’:1:โ‡ก1โ‡ฃ1 on fafd9d0 {1} - โ””โ”€โ”€ :0:A <> origin/A โ†’:1:โ‡ก1โ‡ฃ1 +โŒ‚:A <> โœ“refs/remotes/origin/Aโ‡ฃ1 on fafd9d0 +โ””โ”€โ”€ โ‰ก:A <> origin/Aโ‡ก1โ‡ฃ1 on fafd9d0 {1} + โ””โ”€โ”€ :A <> origin/Aโ‡ก1โ‡ฃ1 โ”œโ”€โ”€ ๐ŸŸฃe29c23d โ””โ”€โ”€ ยทe255adc @@ -1334,14 +1334,14 @@ fn with_limits() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:C[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:C[๐ŸŒณ] {1} - โ”œโ”€โ”€ :0:C[๐ŸŒณ] +โŒ‚:C[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:C[๐ŸŒณ] {1} + โ”œโ”€โ”€ :C[๐ŸŒณ] โ”‚ โ”œโ”€โ”€ ยท2a95729 โ”‚ โ”œโ”€โ”€ ยท6861158 โ”‚ โ”œโ”€โ”€ ยท4f1f248 โ”‚ โ””โ”€โ”€ ยท487ffce - โ””โ”€โ”€ :4:main + โ””โ”€โ”€ :main โ”œโ”€โ”€ ยทedc4dee โ”œโ”€โ”€ ยท01d0e1e โ”œโ”€โ”€ ยท4b3e5a8 @@ -1373,9 +1373,9 @@ fn with_limits() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:C[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:C[๐ŸŒณ] {1} - โ””โ”€โ”€ :0:C[๐ŸŒณ] +โŒ‚:C[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:C[๐ŸŒณ] {1} + โ””โ”€โ”€ :C[๐ŸŒณ] โ””โ”€โ”€ โœ‚๏ธยท2a95729 "#]] @@ -1407,9 +1407,9 @@ fn with_limits() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:C[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:C[๐ŸŒณ] {1} - โ””โ”€โ”€ :0:C[๐ŸŒณ] +โŒ‚:C[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:C[๐ŸŒณ] {1} + โ””โ”€โ”€ :C[๐ŸŒณ] โ”œโ”€โ”€ ยท2a95729 โ””โ”€โ”€ โœ‚๏ธยท6861158 @@ -1474,9 +1474,9 @@ fn with_limits() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:C[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:C[๐ŸŒณ] {1} - โ””โ”€โ”€ :0:C[๐ŸŒณ] +โŒ‚:C[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:C[๐ŸŒณ] {1} + โ””โ”€โ”€ :C[๐ŸŒณ] โ”œโ”€โ”€ ยท2a95729 โ”œโ”€โ”€ ยท6861158 โ””โ”€โ”€ โœ‚๏ธยท4f1f248 @@ -1517,9 +1517,9 @@ fn with_limits() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:C[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:C[๐ŸŒณ] {1} - โ””โ”€โ”€ :0:C[๐ŸŒณ] +โŒ‚:C[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:C[๐ŸŒณ] {1} + โ””โ”€โ”€ :C[๐ŸŒณ] โ”œโ”€โ”€ ยท2a95729 โ”œโ”€โ”€ ยท6861158 โ””โ”€โ”€ โœ‚๏ธยท4f1f248 @@ -1617,9 +1617,9 @@ Statistics { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:C[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:C[๐ŸŒณ] {1} - โ””โ”€โ”€ :0:C[๐ŸŒณ] +โŒ‚:C[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:C[๐ŸŒณ] {1} + โ””โ”€โ”€ :C[๐ŸŒณ] โ”œโ”€โ”€ ยท2a95729 โ”œโ”€โ”€ ยท6861158 โ”œโ”€โ”€ ยท4f1f248 @@ -1671,9 +1671,9 @@ Statistics { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:C[๐ŸŒณ] <> โœ“! on edc4dee -โ””โ”€โ”€ โ‰ก:0:C[๐ŸŒณ] on edc4dee {1} - โ””โ”€โ”€ :0:C[๐ŸŒณ] +โŒ‚:C[๐ŸŒณ] <> โœ“! on edc4dee +โ””โ”€โ”€ โ‰ก:C[๐ŸŒณ] on edc4dee {1} + โ””โ”€โ”€ :C[๐ŸŒณ] โ”œโ”€โ”€ ยท2a95729 โ”œโ”€โ”€ ยท6861158 โ”œโ”€โ”€ ยท4f1f248 @@ -1723,9 +1723,9 @@ fn special_branch_names_do_not_end_up_in_segment() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ] +โŒ‚:main[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] {1} + โ””โ”€โ”€ :main[๐ŸŒณ] โ”œโ”€โ”€ ยท3686017 โ”œโ”€โ”€ ยท9725482 โ””โ”€โ”€ ยทfafd9d0 @@ -1766,9 +1766,9 @@ fn ambiguous_worktrees() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ@repo] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ@repo] {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ@repo] +โŒ‚:main[๐ŸŒณ@repo] <> โœ“! +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ@repo] {1} + โ””โ”€โ”€ :main[๐ŸŒณ@repo] โ””โ”€โ”€ ยท85efbe4 โ–บwt-inside-ambiguous-worktree[๐Ÿ“], โ–บwt-outside-ambiguous-worktree[๐Ÿ“] "#]] @@ -1804,9 +1804,9 @@ fn ambiguous_worktrees() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:wt-inside-ambiguous-worktree[๐Ÿ“@repo] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:wt-inside-ambiguous-worktree[๐Ÿ“@repo] {1} - โ””โ”€โ”€ :0:wt-inside-ambiguous-worktree[๐Ÿ“@repo] +โŒ‚:wt-inside-ambiguous-worktree[๐Ÿ“@repo] <> โœ“! +โ””โ”€โ”€ โ‰ก:wt-inside-ambiguous-worktree[๐Ÿ“@repo] {1} + โ””โ”€โ”€ :wt-inside-ambiguous-worktree[๐Ÿ“@repo] โ””โ”€โ”€ ยท85efbe4 โ–บmain[๐ŸŒณ], โ–บwt-outside-ambiguous-worktree[๐Ÿ“] "#]] @@ -1913,10 +1913,10 @@ fn ad_hoc_same_tip_order_creates_empty_branch_segments() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:1:top <> โœ“! -โ””โ”€โ”€ โ‰ก:1:top {1} - โ”œโ”€โ”€ :1:top - โ””โ”€โ”€ :0:bottom +โŒ‚:top <> โœ“! +โ””โ”€โ”€ โ‰ก:top {1} + โ”œโ”€โ”€ :top + โ””โ”€โ”€ :bottom โ””โ”€โ”€ ยท960152d โ–บmain[๐ŸŒณ] "#]] @@ -1952,9 +1952,9 @@ fn ad_hoc_order_projects_from_entrypoint_when_top_is_above_it() -> anyhow::Resul snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:bottom <> โœ“! -โ””โ”€โ”€ โ‰ก:0:bottom {1} - โ””โ”€โ”€ :0:bottom +โŒ‚:bottom <> โœ“! +โ””โ”€โ”€ โ‰ก:bottom {1} + โ””โ”€โ”€ :bottom โ””โ”€โ”€ ยท960152d โ–บmain[๐ŸŒณ] "#]] @@ -1995,11 +1995,11 @@ fn ad_hoc_three_branch_order_preserves_middle_empty_segment() -> anyhow::Result< snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:1:top <> โœ“! -โ””โ”€โ”€ โ‰ก:1:top {1} - โ”œโ”€โ”€ :1:top - โ”œโ”€โ”€ :2:middle - โ””โ”€โ”€ :0:bottom +โŒ‚:top <> โœ“! +โ””โ”€โ”€ โ‰ก:top {1} + โ”œโ”€โ”€ :top + โ”œโ”€โ”€ :middle + โ””โ”€โ”€ :bottom โ””โ”€โ”€ ยท960152d โ–บmain[๐ŸŒณ] "#]] @@ -2035,10 +2035,10 @@ fn ad_hoc_order_ignores_missing_metadata_refs_without_phantoms() -> anyhow::Resu snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:1:top <> โœ“! -โ””โ”€โ”€ โ‰ก:1:top {1} - โ”œโ”€โ”€ :1:top - โ””โ”€โ”€ :0:bottom +โŒ‚:top <> โœ“! +โ””โ”€โ”€ โ‰ก:top {1} + โ”œโ”€โ”€ :top + โ””โ”€โ”€ :bottom โ””โ”€โ”€ ยท960152d โ–บmain[๐ŸŒณ] "#]] @@ -2076,9 +2076,9 @@ fn ad_hoc_order_does_not_force_diverged_refs_into_empty_stack() -> anyhow::Resul snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:top <> โœ“! -โ””โ”€โ”€ โ‰ก:0:top {1} - โ””โ”€โ”€ :0:top +โŒ‚:top <> โœ“! +โ””โ”€โ”€ โ‰ก:top {1} + โ””โ”€โ”€ :top โ”œโ”€โ”€ ยท5cd63e5 โ””โ”€โ”€ ยทfa91c94 โ–บbottom, โ–บmain[๐ŸŒณ] @@ -2223,10 +2223,10 @@ fn ad_hoc_order_scopes_empty_segments_to_active_chain() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:1:top <> โœ“! -โ””โ”€โ”€ โ‰ก:1:top {1} - โ”œโ”€โ”€ :1:top - โ””โ”€โ”€ :0:bottom +โŒ‚:top <> โœ“! +โ””โ”€โ”€ โ‰ก:top {1} + โ”œโ”€โ”€ :top + โ””โ”€โ”€ :bottom โ””โ”€โ”€ ยท960152d โ–บmain[๐ŸŒณ], โ–บother-bottom, โ–บother-top "#]] diff --git a/crates/but-graph/tests/graph/init/with_workspace.rs b/crates/but-graph/tests/graph/init/with_workspace.rs index d20523bd315..34ca68838af 100644 --- a/crates/but-graph/tests/graph/init/with_workspace.rs +++ b/crates/but-graph/tests/graph/init/with_workspace.rs @@ -98,14 +98,14 @@ Commit(59a427f, โŒ‚|๐Ÿ˜|1) snapbox::assert_data_eq!( graph_workspace(ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on fafd9d0 -โ”œโ”€โ”€ โ‰ก:1:main <> origin/main โ†’:2:โ‡ก1โ‡ฃ1 on fafd9d0 -โ”‚ โ””โ”€โ”€ :1:main <> origin/main โ†’:2:โ‡ก1โ‡ฃ1 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on fafd9d0 +โ”œโ”€โ”€ โ‰ก:main <> origin/mainโ‡ก1โ‡ฃ1 on fafd9d0 +โ”‚ โ””โ”€โ”€ :main <> origin/mainโ‡ก1โ‡ฃ1 โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ1f5c47b โ”‚ โ”œโ”€โ”€ ยท0a415d8 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ โ„๏ธ73ba99d (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก:3:A on fafd9d0 - โ””โ”€โ”€ :3:A +โ””โ”€โ”€ โ‰ก:A on fafd9d0 + โ””โ”€โ”€ :A โ”œโ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) @@ -157,7 +157,7 @@ fn workspace_with_only_local_target() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 0a415d8 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 0a415d8 "#]] ); @@ -191,14 +191,14 @@ fn reproduce_11483() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 3183e43 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท7236012 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 3183e43 {2} - โ”œโ”€โ”€ ๐Ÿ“™:4:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {2} + โ”œโ”€โ”€ ๐Ÿ“™:B โ”‚ โ””โ”€โ”€ ยท68c8a9d (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:below + โ””โ”€โ”€ ๐Ÿ“™:below "#]] ); @@ -212,13 +212,13 @@ fn reproduce_11483() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 3183e43 {1} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {1} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A โ”‚ โ”‚ โ””โ”€โ”€ ยท7236012 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:5:below -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 3183e43 {2} - โ””โ”€โ”€ ๐Ÿ“™:4:B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:below +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {2} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยท68c8a9d (๐Ÿ˜๏ธ) "#]] @@ -271,12 +271,12 @@ fn workspace_projection_with_advanced_stack_tip() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:B โ†’:3: on 85efbe4 {1} - โ”œโ”€โ”€ ๐Ÿ“™:5:B โ†’:3: +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {1} + โ”œโ”€โ”€ ๐Ÿ“™:B โ”‚ โ”œโ”€โ”€ ยทcc0bf57* โ”‚ โ””โ”€โ”€ ยทd69fe94 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:4:A + โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] @@ -318,13 +318,13 @@ fn no_overzealous_stacks_due_to_workspace_metadata() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก:7:anon: on a821094 {2} -โ”‚ โ””โ”€โ”€ :7:anon: +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก:anon: on a821094 {2} +โ”‚ โ””โ”€โ”€ :anon: โ”‚ โ”œโ”€โ”€ ยท835086d (๐Ÿ˜๏ธ) โ–บfour, โ–บthree โ”‚ โ””โ”€โ”€ ยทff310d3 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:X <> origin/X โ†’:5:โ‡ก1 on 3183e43 {1} - โ””โ”€โ”€ ๐Ÿ“™:3:X <> origin/X โ†’:5:โ‡ก1 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:X <> origin/Xโ‡ก1 on 3183e43 {1} + โ””โ”€โ”€ ๐Ÿ“™:X <> origin/Xโ‡ก1 โ”œโ”€โ”€ ยท0b203b5 (๐Ÿ˜๏ธ) โ””โ”€โ”€ โ„๏ธ4840f3b (๐Ÿ˜๏ธ) @@ -379,9 +379,9 @@ fn single_stack_ambiguous() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:3:B <> origin/B โ†’:4:โ‡ก3 on fafd9d0 - โ””โ”€โ”€ :3:B <> origin/B โ†’:4:โ‡ก3 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:B <> origin/Bโ‡ก3 on fafd9d0 + โ””โ”€โ”€ :B <> origin/Bโ‡ก3 โ”œโ”€โ”€ ยท70e9a36 (๐Ÿ˜๏ธ) โ”œโ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) โ–บtags/without-ref โ”œโ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) โ–บB-empty, โ–บambiguous-01 @@ -429,11 +429,11 @@ fn single_stack_ambiguous() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:4:B <> origin/B โ†’:5:โ‡ก1 on fafd9d0 - โ”œโ”€โ”€ :4:B <> origin/B โ†’:5:โ‡ก1 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:B <> origin/Bโ‡ก1 on fafd9d0 + โ”œโ”€โ”€ :B <> origin/Bโ‡ก1 โ”‚ โ””โ”€โ”€ ยท70e9a36 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ‘‰:0:tags/without-ref + โ””โ”€โ”€ ๐Ÿ‘‰:tags/without-ref โ”œโ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) โ”œโ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) โ–บB-empty, โ–บambiguous-01 โ””โ”€โ”€ โ„70bde6b (๐Ÿ˜๏ธ) โ–บA, โ–บA-empty-01, โ–บA-empty-02, โ–บA-empty-03 @@ -477,11 +477,11 @@ fn single_stack_ambiguous() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:4:B <> origin/B โ†’:5:โ‡ก1 on fafd9d0 - โ”œโ”€โ”€ :4:B <> origin/B โ†’:5:โ‡ก1 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:B <> origin/Bโ‡ก1 on fafd9d0 + โ”œโ”€โ”€ :B <> origin/Bโ‡ก1 โ”‚ โ””โ”€โ”€ ยท70e9a36 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ‘‰:0:anon: + โ””โ”€โ”€ ๐Ÿ‘‰:anon: โ”œโ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) โ–บtags/without-ref โ”œโ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) โ–บB-empty, โ–บambiguous-01 โ””โ”€โ”€ โ„70bde6b (๐Ÿ˜๏ธ) โ–บA, โ–บA-empty-01, โ–บA-empty-02, โ–บA-empty-03 @@ -526,12 +526,12 @@ fn single_stack_ambiguous() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:4:B <> origin/B โ†’:5:โ‡ก2 on fafd9d0 - โ”œโ”€โ”€ :4:B <> origin/B โ†’:5:โ‡ก2 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:B <> origin/Bโ‡ก2 on fafd9d0 + โ”œโ”€โ”€ :B <> origin/Bโ‡ก2 โ”‚ โ”œโ”€โ”€ ยท70e9a36 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) โ–บtags/without-ref - โ””โ”€โ”€ ๐Ÿ‘‰:0:anon: + โ””โ”€โ”€ ๐Ÿ‘‰:anon: โ”œโ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) โ–บB-empty, โ–บambiguous-01 โ””โ”€โ”€ โ„70bde6b (๐Ÿ˜๏ธ) โ–บA, โ–บA-empty-01, โ–บA-empty-02, โ–บA-empty-03 @@ -573,12 +573,12 @@ fn single_stack_ambiguous() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:4:B <> origin/B โ†’:5:โ‡ก2 on fafd9d0 - โ”œโ”€โ”€ :4:B <> origin/B โ†’:5:โ‡ก2 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:B <> origin/Bโ‡ก2 on fafd9d0 + โ”œโ”€โ”€ :B <> origin/Bโ‡ก2 โ”‚ โ”œโ”€โ”€ ยท70e9a36 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) โ–บtags/without-ref - โ””โ”€โ”€ ๐Ÿ‘‰:0:B-empty + โ””โ”€โ”€ ๐Ÿ‘‰:B-empty โ”œโ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) โ–บambiguous-01 โ””โ”€โ”€ โ„70bde6b (๐Ÿ˜๏ธ) โ–บA, โ–บA-empty-01, โ–บA-empty-02, โ–บA-empty-03 @@ -651,16 +651,16 @@ fn single_stack_ws_insertions() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B <> origin/B โ†’:6:โ‡ก2 on fafd9d0 {0} - โ”œโ”€โ”€ ๐Ÿ“™:4:B <> origin/B โ†’:6:โ‡ก2 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B <> origin/Bโ‡ก2 on fafd9d0 {0} + โ”œโ”€โ”€ ๐Ÿ“™:B <> origin/Bโ‡ก2 โ”‚ โ”œโ”€โ”€ ยท70e9a36 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) โ–บtags/without-ref - โ”œโ”€โ”€ ๐Ÿ“™:3:B-empty + โ”œโ”€โ”€ ๐Ÿ“™:B-empty โ”‚ โ””โ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) โ–บambiguous-01 - โ”œโ”€โ”€ ๐Ÿ“™:7:A-empty-03 - โ”œโ”€โ”€ ๐Ÿ“™:8:A-empty-01 - โ””โ”€โ”€ ๐Ÿ“™:9:A + โ”œโ”€โ”€ ๐Ÿ“™:A-empty-03 + โ”œโ”€โ”€ ๐Ÿ“™:A-empty-01 + โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ โ„70bde6b (๐Ÿ˜๏ธ) โ–บA-empty-02 "#]] @@ -708,14 +708,14 @@ fn single_stack_ws_insertions() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B <> origin/B โ†’:6:โ‡ก2 on fafd9d0 {0} - โ”œโ”€โ”€ ๐Ÿ“™:4:B <> origin/B โ†’:6:โ‡ก2 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B <> origin/Bโ‡ก2 on fafd9d0 {0} + โ”œโ”€โ”€ ๐Ÿ“™:B <> origin/Bโ‡ก2 โ”‚ โ”œโ”€โ”€ ยท70e9a36 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) โ–บtags/without-ref - โ”œโ”€โ”€ ๐Ÿ“™:3:B-empty + โ”œโ”€โ”€ ๐Ÿ“™:B-empty โ”‚ โ””โ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) โ–บambiguous-01 - โ””โ”€โ”€ ๐Ÿ“™:10:A + โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ โ„70bde6b (๐Ÿ˜๏ธ) "#]] @@ -761,14 +761,14 @@ fn single_stack_ws_insertions() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:B <> origin/B โ†’:6:โ‡ก2 on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:5:B <> origin/B โ†’:6:โ‡ก2 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B <> origin/Bโ‡ก2 on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:B <> origin/Bโ‡ก2 โ”‚ โ”œโ”€โ”€ ยท70e9a36 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) โ–บtags/without-ref - โ”œโ”€โ”€ ๐Ÿ“™:4:B-empty + โ”œโ”€โ”€ ๐Ÿ“™:B-empty โ”‚ โ””โ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) โ–บambiguous-01 - โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:8:A-empty-01 + โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:A-empty-01 โ””โ”€โ”€ โ„70bde6b (๐Ÿ˜๏ธ) โ–บA-empty-02, โ–บA-empty-03 "#]] @@ -791,19 +791,19 @@ fn single_stack_ws_insertions() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:5:B <> origin/B โ†’:6:โ‡ก2 on fafd9d0 {1} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:5:B <> origin/B โ†’:6:โ‡ก2 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B <> origin/Bโ‡ก2 on fafd9d0 {1} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B <> origin/Bโ‡ก2 โ”‚ โ”‚ โ”œโ”€โ”€ ยท70e9a36 (๐Ÿ˜๏ธ) โ”‚ โ”‚ โ””โ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) โ–บtags/without-ref -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:4:B-empty +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B-empty โ”‚ โ”‚ โ””โ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) โ–บambiguous-01 -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:10:A-empty-01 +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A-empty-01 โ”‚ โ””โ”€โ”€ โ„70bde6b (๐Ÿ˜๏ธ) โ–บA-empty-02, โ–บA-empty-03 -โ”œโ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:7:new-A on fafd9d0 {2} -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:7:new-A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:8:new-B on fafd9d0 {3} - โ””โ”€โ”€ ๐Ÿ“™:8:new-B +โ”œโ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:new-A on fafd9d0 {2} +โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:new-A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:new-B on fafd9d0 {3} + โ””โ”€โ”€ ๐Ÿ“™:new-B "#]] ); @@ -852,13 +852,13 @@ fn single_stack() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:3:B on fafd9d0 - โ”œโ”€โ”€ :3:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:B on fafd9d0 + โ”œโ”€โ”€ :B โ”‚ โ””โ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ :4:B-sub + โ”œโ”€โ”€ :B-sub โ”‚ โ””โ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :5:A + โ””โ”€โ”€ :A โ””โ”€โ”€ ยท70bde6b (๐Ÿ˜๏ธ) "#]] @@ -901,16 +901,16 @@ fn single_stack() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:B on fafd9d0 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:3:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on fafd9d0 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B โ”‚ โ”‚ โ””โ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:4:B-sub +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B-sub โ”‚ โ”‚ โ””โ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:5:A +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท70bde6b (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:6:new-A on fafd9d0 {1} - โ””โ”€โ”€ ๐Ÿ“™:6:new-A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:new-A on fafd9d0 {1} + โ””โ”€โ”€ ๐Ÿ“™:new-A "#]] ); @@ -946,11 +946,11 @@ fn single_merge_into_main_base_archived() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 0cc5a6f -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:C on 0cc5a6f {0} - โ”œโ”€โ”€ ๐Ÿ“™:3:C +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 0cc5a6f +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 0cc5a6f {0} + โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยทc6d714c (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:7:merge + โ””โ”€โ”€ ๐Ÿ“™:merge "#]] ); @@ -972,9 +972,9 @@ fn single_merge_into_main_base_archived() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 0cc5a6f -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:C {0} - โ””โ”€โ”€ ๐Ÿ“™:3:C +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 0cc5a6f +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C {0} + โ””โ”€โ”€ ๐Ÿ“™:C โ””โ”€โ”€ ยทc6d714c (๐Ÿ˜๏ธ) "#]] @@ -989,12 +989,12 @@ fn single_merge_into_main_base_archived() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 0cc5a6f -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:C on 0cc5a6f {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:C +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 0cc5a6f +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 0cc5a6f {0} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยทc6d714c (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:7:merge on 0cc5a6f {1} - โ””โ”€โ”€ ๐Ÿ“™:7:merge +โ””โ”€โ”€ โ‰ก๐Ÿ“™:merge on 0cc5a6f {1} + โ””โ”€โ”€ ๐Ÿ“™:merge "#]] ); @@ -1008,11 +1008,11 @@ fn single_merge_into_main_base_archived() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 0cc5a6f -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:7:merge on 0cc5a6f {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:7:merge -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:C on 0cc5a6f {1} - โ””โ”€โ”€ ๐Ÿ“™:3:C +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 0cc5a6f +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:merge on 0cc5a6f {0} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:merge +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 0cc5a6f {1} + โ””โ”€โ”€ ๐Ÿ“™:C โ””โ”€โ”€ ยทc6d714c (๐Ÿ˜๏ธ) "#]] @@ -1077,9 +1077,9 @@ fn minimal_merge_no_refs() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:gitbutler/workspace[๐ŸŒณ] {1} - โ””โ”€โ”€ :0:gitbutler/workspace[๐ŸŒณ] +โŒ‚:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:gitbutler/workspace[๐ŸŒณ] {1} + โ””โ”€โ”€ :gitbutler/workspace[๐ŸŒณ] โ”œโ”€โ”€ ยท47e1cf1 โ”œโ”€โ”€ ยทf40fb16 โ”œโ”€โ”€ ยท450c58a @@ -1141,9 +1141,9 @@ fn segment_on_each_incoming_connection() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:entrypoint <> โœ“! -โ””โ”€โ”€ โ‰ก:0:entrypoint {1} - โ””โ”€โ”€ :0:entrypoint +โŒ‚:entrypoint <> โœ“! +โ””โ”€โ”€ โ‰ก:entrypoint {1} + โ””โ”€โ”€ :entrypoint โ”œโ”€โ”€ ยท98c5aba โ”œโ”€โ”€ ยท807b6ce โ”œโ”€โ”€ ยท6d05486 @@ -1213,18 +1213,18 @@ fn minimal_merge() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:gitbutler/workspace[๐ŸŒณ] {1} - โ”œโ”€โ”€ :0:gitbutler/workspace[๐ŸŒณ] +โŒ‚:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:gitbutler/workspace[๐ŸŒณ] {1} + โ”œโ”€โ”€ :gitbutler/workspace[๐ŸŒณ] โ”‚ โ””โ”€โ”€ ยท47e1cf1 - โ”œโ”€โ”€ :1:merge-2 + โ”œโ”€โ”€ :merge-2 โ”‚ โ””โ”€โ”€ ยทf40fb16 - โ”œโ”€โ”€ :2:D + โ”œโ”€โ”€ :D โ”‚ โ”œโ”€โ”€ ยท450c58a โ”‚ โ””โ”€โ”€ ยท0cc5a6f โ–บempty-1-on-merge, โ–บempty-2-on-merge, โ–บmerge - โ”œโ”€โ”€ :5:B + โ”œโ”€โ”€ :B โ”‚ โ””โ”€โ”€ ยท7fdb58d - โ””โ”€โ”€ :7:main <> origin/main โ†’:8: + โ””โ”€โ”€ :main <> origin/main โ””โ”€โ”€ โ„๏ธfafd9d0 "#]] @@ -1273,17 +1273,17 @@ fn minimal_merge() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:6:merge-2 on fafd9d0 {0} - โ”œโ”€โ”€ :6:merge-2 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:merge-2 on fafd9d0 {0} + โ”œโ”€โ”€ :merge-2 โ”‚ โ””โ”€โ”€ ยทf40fb16 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ :7:D + โ”œโ”€โ”€ :D โ”‚ โ””โ”€โ”€ ยท450c58a (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ๐Ÿ“™:9:empty-2-on-merge - โ”œโ”€โ”€ ๐Ÿ“™:10:empty-1-on-merge - โ”œโ”€โ”€ ๐Ÿ“™:11:merge + โ”œโ”€โ”€ ๐Ÿ“™:empty-2-on-merge + โ”œโ”€โ”€ ๐Ÿ“™:empty-1-on-merge + โ”œโ”€โ”€ ๐Ÿ“™:merge โ”‚ โ””โ”€โ”€ ยท0cc5a6f (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :4:B + โ””โ”€โ”€ :B โ””โ”€โ”€ ยท7fdb58d (๐Ÿ˜๏ธ) "#]] @@ -1363,14 +1363,14 @@ fn entrypoint_inside_second_parent_of_workspace_diamond_is_included() -> anyhow: snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:5:merge-2 on fafd9d0 - โ”œโ”€โ”€ :5:merge-2 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:merge-2 on fafd9d0 + โ”œโ”€โ”€ :merge-2 โ”‚ โ””โ”€โ”€ ยทf40fb16 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ๐Ÿ‘‰:0:C + โ”œโ”€โ”€ ๐Ÿ‘‰:C โ”‚ โ”œโ”€โ”€ ยทc6d714c (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยท0cc5a6f (๐Ÿ˜๏ธ) โ–บempty-1-on-merge, โ–บempty-2-on-merge, โ–บmerge - โ””โ”€โ”€ :6:B + โ””โ”€โ”€ :B โ””โ”€โ”€ ยท7fdb58d (๐Ÿ˜๏ธ) "#]] @@ -1434,11 +1434,11 @@ fn stack_configuration_is_respected_if_one_of_them_is_an_entrypoint() -> anyhow: snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:A on fafd9d0 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:2:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:B on fafd9d0 {2} - โ””โ”€โ”€ ๐Ÿ“™:3:B +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on fafd9d0 {2} + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -1468,11 +1468,11 @@ fn stack_configuration_is_respected_if_one_of_them_is_an_entrypoint() -> anyhow: snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“! on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:A on fafd9d0 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:2:A -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:3:B on fafd9d0 {2} - โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:3:B +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:B on fafd9d0 {2} + โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:B "#]] ); @@ -1502,11 +1502,11 @@ fn stack_configuration_is_respected_if_one_of_them_is_an_entrypoint() -> anyhow: snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“! on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:2:A on fafd9d0 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:2:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:B on fafd9d0 {2} - โ””โ”€โ”€ ๐Ÿ“™:3:B +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:A on fafd9d0 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on fafd9d0 {2} + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -1550,9 +1550,9 @@ fn just_init_with_branches() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“refs/remotes/origin/main -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] <> origin/main โ†’:2: {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ] <> origin/main โ†’:2: +โŒ‚:main[๐ŸŒณ] <> โœ“refs/remotes/origin/main +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] <> origin/main {1} + โ””โ”€โ”€ :main[๐ŸŒณ] <> origin/main "#]] ); @@ -1583,9 +1583,9 @@ fn just_init_with_branches() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace <> โœ“! -โ””โ”€โ”€ โ‰ก:1:main[๐ŸŒณ] <> origin/main โ†’:2: - โ””โ”€โ”€ :1:main[๐ŸŒณ] <> origin/main โ†’:2: +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“! +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] <> origin/main + โ””โ”€โ”€ :main[๐ŸŒณ] <> origin/main โ””โ”€โ”€ โ„๏ธfafd9d0 (๐Ÿ˜๏ธ) โ–บA, โ–บB, โ–บC, โ–บD, โ–บE, โ–บF "#]] @@ -1626,15 +1626,15 @@ fn just_init_with_branches() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:1:gitbutler/workspace <> โœ“! on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:C on fafd9d0 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:3:C -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:4:B -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:5:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:6:D on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:6:D - โ”œโ”€โ”€ ๐Ÿ“™:7:E - โ””โ”€โ”€ ๐Ÿ“™:8:F +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“! on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on fafd9d0 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:D on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:D + โ”œโ”€โ”€ ๐Ÿ“™:E + โ””โ”€โ”€ ๐Ÿ“™:F "#]] ); @@ -1672,15 +1672,15 @@ fn just_init_with_branches() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:C on fafd9d0 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:3:C -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:4:B -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:5:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:6:D on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:6:D - โ”œโ”€โ”€ ๐Ÿ“™:7:E - โ””โ”€โ”€ ๐Ÿ“™:8:F +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on fafd9d0 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:D on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:D + โ”œโ”€โ”€ ๐Ÿ“™:E + โ””โ”€โ”€ ๐Ÿ“™:F "#]] ); @@ -1689,15 +1689,15 @@ fn just_init_with_branches() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws.graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:A <> โœ“! on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on fafd9d0 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:3:A -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:4:B -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:5:C -โ””โ”€โ”€ โ‰ก๐Ÿ“™:6:D on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:6:D - โ”œโ”€โ”€ ๐Ÿ“™:7:E - โ””โ”€โ”€ ๐Ÿ“™:8:F +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:A <> โœ“! on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:D on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:D + โ”œโ”€โ”€ ๐Ÿ“™:E + โ””โ”€โ”€ ๐Ÿ“™:F "#]] ); @@ -1730,7 +1730,7 @@ fn just_init_with_branches() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 "#]] ); @@ -1809,15 +1809,15 @@ fn tips_equivalent_to_workspace_metadata_are_order_independent() -> anyhow::Resu snapbox::assert_data_eq!( workspace_baseline_workspace.to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:C on fafd9d0 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:3:C -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:4:B -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:5:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:6:D on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:6:D - โ”œโ”€โ”€ ๐Ÿ“™:7:E - โ””โ”€โ”€ ๐Ÿ“™:8:F +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on fafd9d0 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:D on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:D + โ”œโ”€โ”€ ๐Ÿ“™:E + โ””โ”€โ”€ ๐Ÿ“™:F "#]] ); @@ -1890,15 +1890,15 @@ fn tips_equivalent_to_workspace_metadata_are_order_independent() -> anyhow::Resu snapbox::assert_data_eq!( explicit_workspace.to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:C on fafd9d0 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:3:C -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:4:B -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:5:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:6:D on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:6:D - โ”œโ”€โ”€ ๐Ÿ“™:7:E - โ””โ”€โ”€ ๐Ÿ“™:8:F +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on fafd9d0 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:D on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:D + โ”œโ”€โ”€ ๐Ÿ“™:E + โ””โ”€โ”€ ๐Ÿ“™:F "#]] ); @@ -2010,11 +2010,11 @@ fn just_init_with_archived_branches() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:C on fafd9d0 {0} - โ”œโ”€โ”€ ๐Ÿ“™:3:C - โ”œโ”€โ”€ ๐Ÿ“™:4:B - โ””โ”€โ”€ ๐Ÿ“™:5:A +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on fafd9d0 {0} + โ”œโ”€โ”€ ๐Ÿ“™:C + โ”œโ”€โ”€ ๐Ÿ“™:B + โ””โ”€โ”€ ๐Ÿ“™:A "#]] ); @@ -2034,9 +2034,9 @@ fn just_init_with_archived_branches() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:C {0} - โ””โ”€โ”€ ๐Ÿ“™:3:C +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C {0} + โ””โ”€โ”€ ๐Ÿ“™:C "#]] ); @@ -2053,10 +2053,10 @@ fn just_init_with_archived_branches() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:C {0} - โ”œโ”€โ”€ ๐Ÿ“™:3:C - โ””โ”€โ”€ ๐Ÿ“™:4:B +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C {0} + โ”œโ”€โ”€ ๐Ÿ“™:C + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -2073,7 +2073,7 @@ fn just_init_with_archived_branches() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 "#]] ); @@ -2119,9 +2119,9 @@ fn two_stacks_many_refs() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:3:anon: on fafd9d0 - โ””โ”€โ”€ :3:anon: +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:anon: on fafd9d0 + โ””โ”€โ”€ :anon: โ”œโ”€โ”€ ยท16f132b (๐Ÿ˜๏ธ) โ–บF, โ–บG, โ–บS1 โ””โ”€โ”€ ยท917b9da (๐Ÿ˜๏ธ) โ–บD, โ–บE @@ -2157,9 +2157,9 @@ fn two_stacks_many_refs() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:0:S1 on fafd9d0 - โ””โ”€โ”€ ๐Ÿ‘‰:0:S1 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:S1 on fafd9d0 + โ””โ”€โ”€ ๐Ÿ‘‰:S1 โ”œโ”€โ”€ ยท16f132b (๐Ÿ˜๏ธ) โ–บF, โ–บG โ””โ”€โ”€ ยท917b9da (๐Ÿ˜๏ธ) โ–บD, โ–บE @@ -2204,18 +2204,18 @@ fn two_stacks_many_refs() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:5:C on fafd9d0 {1} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:5:C -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:6:B -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:7:A on fafd9d0 {2} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:7:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:8:S1 on fafd9d0 {3} - โ”œโ”€โ”€ ๐Ÿ“™:8:S1 - โ”œโ”€โ”€ ๐Ÿ“™:9:G - โ”œโ”€โ”€ ๐Ÿ“™:10:F +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on fafd9d0 {1} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {2} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:S1 on fafd9d0 {3} + โ”œโ”€โ”€ ๐Ÿ“™:S1 + โ”œโ”€โ”€ ๐Ÿ“™:G + โ”œโ”€โ”€ ๐Ÿ“™:F โ”‚ โ””โ”€โ”€ ยท16f132b (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:12:E + โ””โ”€โ”€ ๐Ÿ“™:E โ””โ”€โ”€ ยท917b9da (๐Ÿ˜๏ธ) "#]] @@ -2258,18 +2258,18 @@ fn two_stacks_many_refs() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:5:C on fafd9d0 {1} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:5:C -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:6:B -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:7:A on fafd9d0 {2} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:7:A -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:8:S1 on fafd9d0 {3} - โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:8:S1 - โ”œโ”€โ”€ ๐Ÿ“™:9:G - โ”œโ”€โ”€ ๐Ÿ“™:10:F +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on fafd9d0 {1} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {2} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:S1 on fafd9d0 {3} + โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:S1 + โ”œโ”€โ”€ ๐Ÿ“™:G + โ”œโ”€โ”€ ๐Ÿ“™:F โ”‚ โ””โ”€โ”€ ยท16f132b (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:12:E + โ””โ”€โ”€ ๐Ÿ“™:E โ””โ”€โ”€ ยท917b9da (๐Ÿ˜๏ธ) "#]] @@ -2321,17 +2321,17 @@ fn just_init_with_branches_complex() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:C on fafd9d0 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:3:C -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:B -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:5:A on fafd9d0 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:5:A -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:6:D on fafd9d0 {2} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:6:D -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:7:E -โ””โ”€โ”€ โ‰ก๐Ÿ“™:8:F on fafd9d0 {3} - โ””โ”€โ”€ ๐Ÿ“™:8:F +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on fafd9d0 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:D on fafd9d0 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:D +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:E +โ””โ”€โ”€ โ‰ก๐Ÿ“™:F on fafd9d0 {3} + โ””โ”€โ”€ ๐Ÿ“™:F "#]] ); @@ -2373,17 +2373,17 @@ fn just_init_with_branches_complex() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:1:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:3:C on fafd9d0 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:3:C -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:B -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:5:A on fafd9d0 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:5:A -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:6:D on fafd9d0 {2} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:6:D -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:7:E -โ””โ”€โ”€ โ‰ก๐Ÿ“™:8:F on fafd9d0 {3} - โ””โ”€โ”€ ๐Ÿ“™:8:F +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:C on fafd9d0 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:C +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:D on fafd9d0 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:D +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:E +โ””โ”€โ”€ โ‰ก๐Ÿ“™:F on fafd9d0 {3} + โ””โ”€โ”€ ๐Ÿ“™:F "#]] ); @@ -2431,7 +2431,7 @@ fn proper_remote_ahead() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ2 on 998eae6 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ2 on 998eae6 "#]] ); @@ -2469,9 +2469,9 @@ fn proper_remote_ahead() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:main <> โœ“refs/remotes/origin/mainโ‡ฃ2 -โ””โ”€โ”€ โ‰ก:0:main <> origin/main โ†’:2:โ‡ฃ2 {1} - โ””โ”€โ”€ :0:main <> origin/main โ†’:2:โ‡ฃ2 +โŒ‚:main <> โœ“refs/remotes/origin/mainโ‡ฃ2 +โ””โ”€โ”€ โ‰ก:main <> origin/mainโ‡ฃ2 {1} + โ””โ”€โ”€ :main <> origin/mainโ‡ฃ2 โ”œโ”€โ”€ ๐ŸŸฃca7baa7 (โœ“) โ””โ”€โ”€ ๐ŸŸฃ7ea1468 (โœ“) @@ -2534,9 +2534,9 @@ fn deduced_remote_ahead() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:1:A <> origin/A โ†’:2:โ‡ก2โ‡ฃ4 - โ”œโ”€โ”€ :1:A <> origin/A โ†’:2:โ‡ก2โ‡ฃ4 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:A <> origin/Aโ‡ก2โ‡ฃ4 + โ”œโ”€โ”€ :A <> origin/Aโ‡ก2โ‡ฃ4 โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ3ea1a8f โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ9c50f71 โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ2cfbb79 @@ -2544,7 +2544,7 @@ fn deduced_remote_ahead() -> anyhow::Result<()> { โ”‚ โ”œโ”€โ”€ ยท9d34471 (๐Ÿ˜๏ธ) โ”‚ โ”œโ”€โ”€ ยท5b89c71 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ โ„๏ธ998eae6 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :3:main + โ””โ”€โ”€ :main โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) "#]] @@ -2583,9 +2583,9 @@ fn deduced_remote_ahead() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:2:A <> origin/A โ†’:3:โ‡ก2โ‡ฃ4 - โ”œโ”€โ”€ :2:A <> origin/A โ†’:3:โ‡ก2โ‡ฃ4 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:A <> origin/Aโ‡ก2โ‡ฃ4 + โ”œโ”€โ”€ :A <> origin/Aโ‡ก2โ‡ฃ4 โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ3ea1a8f โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ9c50f71 โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ2cfbb79 @@ -2593,7 +2593,7 @@ fn deduced_remote_ahead() -> anyhow::Result<()> { โ”‚ โ”œโ”€โ”€ ยท9d34471 (๐Ÿ˜๏ธ) โ”‚ โ”œโ”€โ”€ ยท5b89c71 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ โ„๏ธ998eae6 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ‘‰:0:main + โ””โ”€โ”€ ๐Ÿ‘‰:main โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) "#]] @@ -2635,9 +2635,9 @@ fn deduced_remote_ahead() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:1:A <> push-remote/A โ†’:2:โ‡ก2โ‡ฃ4 - โ”œโ”€โ”€ :1:A <> push-remote/A โ†’:2:โ‡ก2โ‡ฃ4 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:A <> push-remote/Aโ‡ก2โ‡ฃ4 + โ”œโ”€โ”€ :A <> push-remote/Aโ‡ก2โ‡ฃ4 โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ3ea1a8f โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ9c50f71 โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ2cfbb79 @@ -2645,7 +2645,7 @@ fn deduced_remote_ahead() -> anyhow::Result<()> { โ”‚ โ”œโ”€โ”€ ยท9d34471 (๐Ÿ˜๏ธ) โ”‚ โ”œโ”€โ”€ ยท5b89c71 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ โ„๏ธ998eae6 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :3:main + โ””โ”€โ”€ :main โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) "#]] @@ -2703,13 +2703,13 @@ fn stacked_rebased_remotes() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:1:B - โ”œโ”€โ”€ :1:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:B + โ”œโ”€โ”€ :B โ”‚ โ””โ”€โ”€ ยท312f819 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ :2:A + โ”œโ”€โ”€ :A โ”‚ โ””โ”€โ”€ ยทe255adc (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :3:main <> origin/main โ†’:4: + โ””โ”€โ”€ :main <> origin/main โ””โ”€โ”€ โ„๏ธfafd9d0 (๐Ÿ˜๏ธ) "#]] @@ -2746,12 +2746,12 @@ fn stacked_rebased_remotes() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:5:B <> origin/B โ†’:6:โ‡ก1โ‡ฃ1 on fafd9d0 - โ”œโ”€โ”€ :5:B <> origin/B โ†’:6:โ‡ก1โ‡ฃ1 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:B <> origin/Bโ‡ก1โ‡ฃ1 on fafd9d0 + โ”œโ”€โ”€ :B <> origin/Bโ‡ก1โ‡ฃ1 โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ682be32 โ”‚ โ””โ”€โ”€ ยท312f819 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ‘‰:0:A <> origin/A โ†’:4:โ‡ก1โ‡ฃ1 + โ””โ”€โ”€ ๐Ÿ‘‰:A <> origin/Aโ‡ก1โ‡ฃ1 โ”œโ”€โ”€ ๐ŸŸฃe29c23d โ””โ”€โ”€ ยทe255adc (๐Ÿ˜๏ธ) @@ -2869,9 +2869,9 @@ fn target_with_remote_on_stack_tip() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:2:A on fafd9d0 {1} - โ””โ”€โ”€ ๐Ÿ“™:2:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} + โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ ยทe255adc (๐Ÿ˜๏ธ) "#]] @@ -2884,10 +2884,10 @@ fn target_with_remote_on_stack_tip() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:3:A - โ””โ”€โ”€ ๐Ÿ“™:4:main <> origin/main โ†’:1:โ‡ก1 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ๐Ÿ“™:main <> origin/mainโ‡ก1 โ””โ”€โ”€ ยทe255adc (๐Ÿ˜๏ธ) "#]] @@ -2900,10 +2900,10 @@ fn target_with_remote_on_stack_tip() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:main <> origin/main โ†’:1: on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:3:main <> origin/main โ†’:1: - โ””โ”€โ”€ ๐Ÿ“™:4:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:main <> origin/main on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:main <> origin/main + โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ ยทe255adc (๐Ÿ˜๏ธ) "#]] @@ -2978,14 +2978,14 @@ fn disambiguate_by_remote() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:6:anon: on fafd9d0 - โ”œโ”€โ”€ :6:anon: +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:anon: on fafd9d0 + โ”œโ”€โ”€ :anon: โ”‚ โ””โ”€โ”€ ยท2173153 (๐Ÿ˜๏ธ) โ–บC, โ–บambiguous-C - โ”œโ”€โ”€ :9:B <> origin/B โ†’:5:โ‡ฃ1 + โ”œโ”€โ”€ :B <> origin/Bโ‡ฃ1 โ”‚ โ”œโ”€โ”€ ๐ŸŸฃac24e74 โ”‚ โ””โ”€โ”€ โ„๏ธ312f819 (๐Ÿ˜๏ธ) โ–บambiguous-B - โ””โ”€โ”€ :8:A <> origin/A โ†’:7: + โ””โ”€โ”€ :A <> origin/A โ””โ”€โ”€ โ„๏ธe255adc (๐Ÿ˜๏ธ) โ–บambiguous-A "#]] @@ -3027,14 +3027,14 @@ fn disambiguate_by_remote() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:C <> origin/C โ†’:4: on fafd9d0 {0} - โ”œโ”€โ”€ ๐Ÿ“™:3:C <> origin/C โ†’:4: +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C <> origin/C on fafd9d0 {0} + โ”œโ”€โ”€ ๐Ÿ“™:C <> origin/C โ”‚ โ””โ”€โ”€ โ„๏ธ2173153 (๐Ÿ˜๏ธ) โ–บambiguous-C - โ”œโ”€โ”€ :9:B <> origin/B โ†’:6:โ‡ฃ1 + โ”œโ”€โ”€ :B <> origin/Bโ‡ฃ1 โ”‚ โ”œโ”€โ”€ ๐ŸŸฃac24e74 โ”‚ โ””โ”€โ”€ โ„๏ธ312f819 (๐Ÿ˜๏ธ) โ–บambiguous-B - โ””โ”€โ”€ :8:A <> origin/A โ†’:7: + โ””โ”€โ”€ :A <> origin/A โ””โ”€โ”€ โ„๏ธe255adc (๐Ÿ˜๏ธ) โ–บambiguous-A "#]] @@ -3123,19 +3123,19 @@ fn integrated_tips_stop_early_if_remote_is_not_configured() -> anyhow::Result<() snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:1:B - โ”œโ”€โ”€ :1:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:B + โ”œโ”€โ”€ :B โ”‚ โ”œโ”€โ”€ ยท6b1a13b (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยท03ad472 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ :2:A + โ”œโ”€โ”€ :A โ”‚ โ”œโ”€โ”€ ยท79bbb29 (๐Ÿ˜๏ธ) โ”‚ โ”œโ”€โ”€ ยทfc98174 (๐Ÿ˜๏ธ) โ”‚ โ”œโ”€โ”€ ยทa381df5 (๐Ÿ˜๏ธ) โ”‚ โ”œโ”€โ”€ ยท777b552 (๐Ÿ˜๏ธ) โ”‚ โ”œโ”€โ”€ ยทce4a760 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยท01d0e1e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :6:main + โ””โ”€โ”€ :main โ”œโ”€โ”€ ยท4b3e5a8 (๐Ÿ˜๏ธ) โ”œโ”€โ”€ ยท34d0715 (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยทeb5f731 (๐Ÿ˜๏ธ) @@ -3183,9 +3183,9 @@ fn integrated_tips_stop_early_if_remote_is_not_configured() -> anyhow::Result<() snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ6 on 79bbb29 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:2:B on 79bbb29 {0} - โ””โ”€โ”€ ๐Ÿ“™:2:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ6 on 79bbb29 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 79bbb29 {0} + โ””โ”€โ”€ ๐Ÿ“™:B โ”œโ”€โ”€ ยท6b1a13b (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยท03ad472 (๐Ÿ˜๏ธ) @@ -3231,9 +3231,9 @@ fn integrated_tips_stop_early_if_remote_is_not_configured() -> anyhow::Result<() snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ6 on 79bbb29 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:2:B on 79bbb29 {0} - โ””โ”€โ”€ ๐Ÿ“™:2:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ6 on 79bbb29 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 79bbb29 {0} + โ””โ”€โ”€ ๐Ÿ“™:B โ”œโ”€โ”€ ยท6b1a13b (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยท03ad472 (๐Ÿ˜๏ธ) @@ -3297,9 +3297,9 @@ fn integrated_tips_stop_early_if_remote_is_not_configured() -> anyhow::Result<() snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:A <> โœ“refs/remotes/origin/mainโ‡ฃ3 -โ””โ”€โ”€ โ‰ก:0:A on 4b3e5a8 {1} - โ””โ”€โ”€ :0:A +โŒ‚:A <> โœ“refs/remotes/origin/mainโ‡ฃ3 +โ””โ”€โ”€ โ‰ก:A on 4b3e5a8 {1} + โ””โ”€โ”€ :A "#]] ); @@ -3346,9 +3346,9 @@ fn integrated_tips_stop_early_if_remote_is_not_configured() -> anyhow::Result<() snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:A <> โœ“refs/remotes/origin/mainโ‡ฃ6 -โ””โ”€โ”€ โ‰ก:0:A {1} - โ””โ”€โ”€ :0:A +โŒ‚:A <> โœ“refs/remotes/origin/mainโ‡ฃ6 +โ””โ”€โ”€ โ‰ก:A {1} + โ””โ”€โ”€ :A "#]] ); @@ -3406,9 +3406,9 @@ fn integrated_tips_stop_early_if_remote_is_not_configured() -> anyhow::Result<() snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:DETACHED <> โœ“refs/remotes/origin/mainโ‡ฃ3 on 79bbb29 -โ””โ”€โ”€ โ‰ก:0:anon: on 4b3e5a8 {1} - โ””โ”€โ”€ :0:anon: +โŒ‚:DETACHED <> โœ“refs/remotes/origin/mainโ‡ฃ3 on 79bbb29 +โ””โ”€โ”€ โ‰ก:anon: on 4b3e5a8 {1} + โ””โ”€โ”€ :anon: โ”œโ”€โ”€ ยทd0df794 (โœ“) โ”œโ”€โ”€ ยท09c6e08 (โœ“) โ””โ”€โ”€ ยท7b9f260 (โœ“) @@ -3470,9 +3470,9 @@ fn integrated_tips_stop_early_if_remote_is_not_configured() -> anyhow::Result<() snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:DETACHED <> โœ“refs/remotes/origin/mainโ‡ฃ3 on 79bbb29 -โ””โ”€โ”€ โ‰ก:0:anon: on 4b3e5a8 {1} - โ””โ”€โ”€ :0:anon: +โŒ‚:DETACHED <> โœ“refs/remotes/origin/mainโ‡ฃ3 on 79bbb29 +โ””โ”€โ”€ โ‰ก:anon: on 4b3e5a8 {1} + โ””โ”€โ”€ :anon: โ”œโ”€โ”€ ยทd0df794 (โœ“) โ”œโ”€โ”€ ยท09c6e08 (โœ“) โ””โ”€โ”€ ยท7b9f260 (โœ“) @@ -3561,9 +3561,9 @@ fn integrated_tips_do_not_stop_early() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ3 on 79bbb29 -โ””โ”€โ”€ โ‰ก:4:B on 79bbb29 - โ””โ”€โ”€ :4:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ3 on 79bbb29 +โ””โ”€โ”€ โ‰ก:B on 79bbb29 + โ””โ”€โ”€ :B โ”œโ”€โ”€ ยท6b1a13b (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยท03ad472 (๐Ÿ˜๏ธ) @@ -3581,12 +3581,12 @@ fn integrated_tips_do_not_stop_early() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ3 on 4b3e5a8 -โ””โ”€โ”€ โ‰ก:4:B on 4b3e5a8 - โ”œโ”€โ”€ :4:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ3 on 4b3e5a8 +โ””โ”€โ”€ โ‰ก:B on 4b3e5a8 + โ”œโ”€โ”€ :B โ”‚ โ”œโ”€โ”€ ยท6b1a13b (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยท03ad472 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :5:A + โ””โ”€โ”€ :A โ”œโ”€โ”€ ยท79bbb29 (๐Ÿ˜๏ธ|โœ“) โ”œโ”€โ”€ ยทfc98174 (๐Ÿ˜๏ธ|โœ“) โ”œโ”€โ”€ ยทa381df5 (๐Ÿ˜๏ธ|โœ“) @@ -3651,9 +3651,9 @@ fn integrated_tips_do_not_stop_early() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:A <> โœ“refs/remotes/origin/mainโ‡ฃ3 -โ””โ”€โ”€ โ‰ก:0:A on 4b3e5a8 {1} - โ””โ”€โ”€ :0:A +โŒ‚:A <> โœ“refs/remotes/origin/mainโ‡ฃ3 +โ””โ”€โ”€ โ‰ก:A on 4b3e5a8 {1} + โ””โ”€โ”€ :A "#]] ); @@ -3671,12 +3671,12 @@ fn integrated_tips_do_not_stop_early() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ3 on 4b3e5a8 -โ””โ”€โ”€ โ‰ก:5:B on 4b3e5a8 - โ”œโ”€โ”€ :5:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ3 on 4b3e5a8 +โ””โ”€โ”€ โ‰ก:B on 4b3e5a8 + โ”œโ”€โ”€ :B โ”‚ โ”œโ”€โ”€ ยท6b1a13b (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยท03ad472 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ‘‰:0:A + โ””โ”€โ”€ ๐Ÿ‘‰:A โ”œโ”€โ”€ ยท79bbb29 (๐Ÿ˜๏ธ|โœ“) โ”œโ”€โ”€ ยทfc98174 (๐Ÿ˜๏ธ|โœ“) โ”œโ”€โ”€ ยทa381df5 (๐Ÿ˜๏ธ|โœ“) @@ -3701,9 +3701,9 @@ fn integrated_tips_do_not_stop_early() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:main <> โœ“refs/remotes/origin/mainโ‡ฃ3 -โ””โ”€โ”€ โ‰ก:0:main <> origin/main โ†’:2:โ‡ฃ3 {1} - โ””โ”€โ”€ :0:main <> origin/main โ†’:2:โ‡ฃ3 +โŒ‚:main <> โœ“refs/remotes/origin/mainโ‡ฃ3 +โ””โ”€โ”€ โ‰ก:main <> origin/mainโ‡ฃ3 {1} + โ””โ”€โ”€ :main <> origin/mainโ‡ฃ3 โ”œโ”€โ”€ ๐ŸŸฃd0df794 (โœ“) โ”œโ”€โ”€ ๐ŸŸฃ09c6e08 (โœ“) โ””โ”€โ”€ ๐ŸŸฃ7b9f260 (โœ“) @@ -3720,9 +3720,9 @@ fn integrated_tips_do_not_stop_early() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:DETACHED <> โœ“refs/remotes/origin/mainโ‡ฃ3 -โ””โ”€โ”€ โ‰ก:0:anon: {1} - โ””โ”€โ”€ :0:anon: +โŒ‚:DETACHED <> โœ“refs/remotes/origin/mainโ‡ฃ3 +โ””โ”€โ”€ โ‰ก:anon: {1} + โ””โ”€โ”€ :anon: "#]] ); @@ -3786,9 +3786,9 @@ fn workspace_without_target_can_see_remote() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:1:gitbutler/workspace <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:0:main[๐ŸŒณ] <> origin/main โ†’:2:โ‡ก1 {0} - โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:0:main[๐ŸŒณ] <> origin/main โ†’:2:โ‡ก1 +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:main[๐ŸŒณ] <> origin/mainโ‡ก1 {0} + โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:main[๐ŸŒณ] <> origin/mainโ‡ก1 โ””โ”€โ”€ ยท3183e43 (๐Ÿ˜๏ธ) "#]] @@ -3818,9 +3818,9 @@ fn workspace_without_target_can_see_remote() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:1:gitbutler/workspace <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:0:main[๐ŸŒณ] <> origin/main โ†’:2:โ‡ก1 {0} - โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:0:main[๐ŸŒณ] <> origin/main โ†’:2:โ‡ก1 +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:main[๐ŸŒณ] <> origin/mainโ‡ก1 {0} + โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:main[๐ŸŒณ] <> origin/mainโ‡ก1 โ””โ”€โ”€ ยท3183e43 (๐Ÿ˜๏ธ) "#]] @@ -3887,7 +3887,7 @@ fn workspace_obeys_limit_when_target_branch_is_missing() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! "#]] ); @@ -3938,9 +3938,9 @@ fn workspace_obeys_limit_when_target_branch_is_missing() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ6 on 79bbb29 -โ””โ”€โ”€ โ‰ก:3:B on 79bbb29 - โ””โ”€โ”€ :3:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ6 on 79bbb29 +โ””โ”€โ”€ โ‰ก:B on 79bbb29 + โ””โ”€โ”€ :B โ”œโ”€โ”€ ยท6b1a13b (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยท03ad472 (๐Ÿ˜๏ธ) @@ -3991,9 +3991,9 @@ fn three_branches_one_advanced_ws_commit_advanced_fully_pushed_empty_dependent() snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:4:advanced-lane <> origin/advanced-lane โ†’:3: on fafd9d0 - โ””โ”€โ”€ :4:advanced-lane <> origin/advanced-lane โ†’:3: +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:advanced-lane <> origin/advanced-lane on fafd9d0 + โ””โ”€โ”€ :advanced-lane <> origin/advanced-lane โ””โ”€โ”€ โ„๏ธcbc6713 (๐Ÿ˜๏ธ) โ–บdependent, โ–บon-top-of-dependent "#]] @@ -4033,10 +4033,10 @@ fn three_branches_one_advanced_ws_commit_advanced_fully_pushed_empty_dependent() snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:dependent on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:5:dependent - โ””โ”€โ”€ ๐Ÿ“™:6:advanced-lane <> origin/advanced-lane โ†’:4: +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:dependent on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:dependent + โ””โ”€โ”€ ๐Ÿ“™:advanced-lane <> origin/advanced-lane โ””โ”€โ”€ โ„๏ธcbc6713 (๐Ÿ˜๏ธ) โ–บon-top-of-dependent "#]] @@ -4084,7 +4084,7 @@ fn on_top_of_target_with_history() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 2cde30a +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 2cde30a "#]] ); @@ -4121,15 +4121,15 @@ fn on_top_of_target_with_history() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 2cde30a -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:C on 2cde30a {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:3:C -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:4:B -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:5:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:6:D on 2cde30a {1} - โ”œโ”€โ”€ ๐Ÿ“™:6:D - โ”œโ”€โ”€ ๐Ÿ“™:7:E - โ””โ”€โ”€ ๐Ÿ“™:8:F +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 2cde30a +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 2cde30a {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:D on 2cde30a {1} + โ”œโ”€โ”€ ๐Ÿ“™:D + โ”œโ”€โ”€ ๐Ÿ“™:E + โ””โ”€โ”€ ๐Ÿ“™:F "#]] ); @@ -4147,20 +4147,20 @@ fn on_top_of_target_with_history() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 2be54cd -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:C on 2be54cd {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:3:C -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:4:B -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:5:A +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 2be54cd +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 2be54cd {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ”œโ”€โ”€ ยท2cde30a (๐Ÿ˜๏ธ|โœ“) โ”‚ โ”œโ”€โ”€ ยท1c938f4 (๐Ÿ˜๏ธ|โœ“) โ”‚ โ”œโ”€โ”€ ยทb82769f (๐Ÿ˜๏ธ|โœ“) โ”‚ โ”œโ”€โ”€ ยท988032f (๐Ÿ˜๏ธ|โœ“) โ”‚ โ””โ”€โ”€ ยทcd5b655 (๐Ÿ˜๏ธ|โœ“) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:6:D on 2be54cd {1} - โ”œโ”€โ”€ ๐Ÿ“™:6:D - โ”œโ”€โ”€ ๐Ÿ“™:7:E - โ””โ”€โ”€ ๐Ÿ“™:8:F +โ””โ”€โ”€ โ‰ก๐Ÿ“™:D on 2be54cd {1} + โ”œโ”€โ”€ ๐Ÿ“™:D + โ”œโ”€โ”€ ๐Ÿ“™:E + โ””โ”€โ”€ ๐Ÿ“™:F โ”œโ”€โ”€ ยท2cde30a (๐Ÿ˜๏ธ|โœ“) โ”œโ”€โ”€ ยท1c938f4 (๐Ÿ˜๏ธ|โœ“) โ”œโ”€โ”€ ยทb82769f (๐Ÿ˜๏ธ|โœ“) @@ -4285,9 +4285,9 @@ fn partitions_with_long_and_short_connections_to_each_other() -> anyhow::Result< snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:main <> โœ“refs/remotes/origin/mainโ‡ฃ11 -โ””โ”€โ”€ โ‰ก:0:main <> origin/main โ†’:2:โ‡ฃ11 {1} - โ””โ”€โ”€ :0:main <> origin/main โ†’:2:โ‡ฃ11 +โŒ‚:main <> โœ“refs/remotes/origin/mainโ‡ฃ11 +โ””โ”€โ”€ โ‰ก:main <> origin/mainโ‡ฃ11 {1} + โ””โ”€โ”€ :main <> origin/mainโ‡ฃ11 โ”œโ”€โ”€ ๐ŸŸฃ232ed06 (โœ“) โ”œโ”€โ”€ ๐ŸŸฃabcfd9a (โœ“) โ”œโ”€โ”€ ๐ŸŸฃbc86eba (โœ“) @@ -4362,9 +4362,9 @@ fn partitions_with_long_and_short_connections_to_each_other() -> anyhow::Result< snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:main <> โœ“refs/remotes/origin/mainโ‡ฃ11 -โ””โ”€โ”€ โ‰ก:0:main <> origin/main โ†’:2:โ‡ฃ11 {1} - โ””โ”€โ”€ :0:main <> origin/main โ†’:2:โ‡ฃ11 +โŒ‚:main <> โœ“refs/remotes/origin/mainโ‡ฃ11 +โ””โ”€โ”€ โ‰ก:main <> origin/mainโ‡ฃ11 {1} + โ””โ”€โ”€ :main <> origin/mainโ‡ฃ11 โ”œโ”€โ”€ ๐ŸŸฃ232ed06 (โœ“) โ”œโ”€โ”€ ๐ŸŸฃabcfd9a (โœ“) โ”œโ”€โ”€ ๐ŸŸฃbc86eba (โœ“) @@ -4435,7 +4435,7 @@ fn partitions_with_long_and_short_connections_to_each_other() -> anyhow::Result< snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ11 on 9730cbf +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ11 on 9730cbf "#]] ); @@ -4519,9 +4519,9 @@ fn remote_far_in_ancestry() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 685d644 -โ””โ”€โ”€ โ‰ก:3:A <> origin/A โ†’:4:โ‡ก3โ‡ฃ2 on 685d644 - โ””โ”€โ”€ :3:A <> origin/A โ†’:4:โ‡ก3โ‡ฃ2 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 685d644 +โ””โ”€โ”€ โ‰ก:A <> origin/Aโ‡ก3โ‡ฃ2 on 685d644 + โ””โ”€โ”€ :A <> origin/Aโ‡ก3โ‡ฃ2 โ”œโ”€โ”€ ๐ŸŸฃ975754f โ”œโ”€โ”€ ๐ŸŸฃf48ff69 โ”œโ”€โ”€ ยท8407093 (๐Ÿ˜๏ธ) @@ -4638,9 +4638,9 @@ fn partitions_with_long_and_short_connections_to_each_other_part_2() -> anyhow:: snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -โŒ‚:0:main <> โœ“refs/remotes/origin/mainโ‡ฃ17 -โ””โ”€โ”€ โ‰ก:0:main <> origin/main โ†’:2:โ‡ฃ17 {1} - โ””โ”€โ”€ :0:main <> origin/main โ†’:2:โ‡ฃ17 +โŒ‚:main <> โœ“refs/remotes/origin/mainโ‡ฃ17 +โ””โ”€โ”€ โ‰ก:main <> origin/mainโ‡ฃ17 {1} + โ””โ”€โ”€ :main <> origin/mainโ‡ฃ17 โ”œโ”€โ”€ ๐ŸŸฃ024f837 (โœ“) โ–บlong-workspace-to-target โ”œโ”€โ”€ ๐ŸŸฃ64a8284 (โœ“) โ”œโ”€โ”€ ๐ŸŸฃb72938c (โœ“) @@ -4718,7 +4718,7 @@ fn partitions_with_long_and_short_connections_to_each_other_part_2() -> anyhow:: snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ17 on c9120f1 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ17 on c9120f1 "#]] ); @@ -4734,7 +4734,7 @@ fn partitions_with_long_and_short_connections_to_each_other_part_2() -> anyhow:: snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ17 on c9120f1 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ17 on c9120f1 "#]] ); @@ -4749,7 +4749,7 @@ fn partitions_with_long_and_short_connections_to_each_other_part_2() -> anyhow:: snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ17 on c9120f1 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ17 on c9120f1 "#]] ); @@ -4765,7 +4765,7 @@ fn partitions_with_long_and_short_connections_to_each_other_part_2() -> anyhow:: snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ17 on c9120f1 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ17 on c9120f1 "#]] ); @@ -4852,30 +4852,30 @@ fn multi_lane_with_shared_segment_one_integrated() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 -โ”œโ”€โ”€ โ‰ก:3:D on 3183e43 -โ”‚ โ”œโ”€โ”€ :3:D +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 +โ”œโ”€โ”€ โ‰ก:D on 3183e43 +โ”‚ โ”œโ”€โ”€ :D โ”‚ โ”‚ โ””โ”€โ”€ ยท9895054 (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ :6:C +โ”‚ โ”œโ”€โ”€ :C โ”‚ โ”‚ โ”œโ”€โ”€ ยทde625cc (๐Ÿ˜๏ธ) โ”‚ โ”‚ โ”œโ”€โ”€ ยท23419f8 (๐Ÿ˜๏ธ) โ”‚ โ”‚ โ””โ”€โ”€ ยท5dc4389 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ :7:shared +โ”‚ โ””โ”€โ”€ :shared โ”‚ โ”œโ”€โ”€ ยทd4f537e (๐Ÿ˜๏ธ|โœ“) โ”‚ โ”œโ”€โ”€ ยทb448757 (๐Ÿ˜๏ธ|โœ“) โ”‚ โ””โ”€โ”€ ยทe9a378d (๐Ÿ˜๏ธ|โœ“) -โ”œโ”€โ”€ โ‰ก:4:A on 3183e43 -โ”‚ โ”œโ”€โ”€ :4:A +โ”œโ”€โ”€ โ‰ก:A on 3183e43 +โ”‚ โ”œโ”€โ”€ :A โ”‚ โ”‚ โ””โ”€โ”€ ยท0bad3af (๐Ÿ˜๏ธ|โœ“) -โ”‚ โ””โ”€โ”€ :7:shared +โ”‚ โ””โ”€โ”€ :shared โ”‚ โ”œโ”€โ”€ ยทd4f537e (๐Ÿ˜๏ธ|โœ“) โ”‚ โ”œโ”€โ”€ ยทb448757 (๐Ÿ˜๏ธ|โœ“) โ”‚ โ””โ”€โ”€ ยทe9a378d (๐Ÿ˜๏ธ|โœ“) -โ””โ”€โ”€ โ‰ก:5:B on 3183e43 - โ”œโ”€โ”€ :5:B +โ””โ”€โ”€ โ‰ก:B on 3183e43 + โ”œโ”€โ”€ :B โ”‚ โ”œโ”€โ”€ ยทacdc49a (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยทf0117e0 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :7:shared + โ””โ”€โ”€ :shared โ”œโ”€โ”€ ยทd4f537e (๐Ÿ˜๏ธ|โœ“) โ”œโ”€โ”€ ยทb448757 (๐Ÿ˜๏ธ|โœ“) โ””โ”€โ”€ ยทe9a378d (๐Ÿ˜๏ธ|โœ“) @@ -4889,16 +4889,16 @@ fn multi_lane_with_shared_segment_one_integrated() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on d4f537e -โ”œโ”€โ”€ โ‰ก:3:D on d4f537e -โ”‚ โ”œโ”€โ”€ :3:D +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on d4f537e +โ”œโ”€โ”€ โ‰ก:D on d4f537e +โ”‚ โ”œโ”€โ”€ :D โ”‚ โ”‚ โ””โ”€โ”€ ยท9895054 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ :6:C +โ”‚ โ””โ”€โ”€ :C โ”‚ โ”œโ”€โ”€ ยทde625cc (๐Ÿ˜๏ธ) โ”‚ โ”œโ”€โ”€ ยท23419f8 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยท5dc4389 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก:5:B on d4f537e - โ””โ”€โ”€ :5:B +โ””โ”€โ”€ โ‰ก:B on d4f537e + โ””โ”€โ”€ :B โ”œโ”€โ”€ ยทacdc49a (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยทf0117e0 (๐Ÿ˜๏ธ) @@ -4975,30 +4975,30 @@ fn multi_lane_with_shared_segment() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 -โ”œโ”€โ”€ โ‰ก:2:D on 3183e43 -โ”‚ โ”œโ”€โ”€ :2:D +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 +โ”œโ”€โ”€ โ‰ก:D on 3183e43 +โ”‚ โ”œโ”€โ”€ :D โ”‚ โ”‚ โ””โ”€โ”€ ยท9895054 (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ :6:C +โ”‚ โ”œโ”€โ”€ :C โ”‚ โ”‚ โ”œโ”€โ”€ ยทde625cc (๐Ÿ˜๏ธ) โ”‚ โ”‚ โ”œโ”€โ”€ ยท23419f8 (๐Ÿ˜๏ธ) โ”‚ โ”‚ โ””โ”€โ”€ ยท5dc4389 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ :7:shared +โ”‚ โ””โ”€โ”€ :shared โ”‚ โ”œโ”€โ”€ ยทd4f537e (๐Ÿ˜๏ธ) โ”‚ โ”œโ”€โ”€ ยทb448757 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยทe9a378d (๐Ÿ˜๏ธ) -โ”œโ”€โ”€ โ‰ก:3:A on 3183e43 -โ”‚ โ”œโ”€โ”€ :3:A +โ”œโ”€โ”€ โ‰ก:A on 3183e43 +โ”‚ โ”œโ”€โ”€ :A โ”‚ โ”‚ โ””โ”€โ”€ ยท0bad3af (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ :7:shared +โ”‚ โ””โ”€โ”€ :shared โ”‚ โ”œโ”€โ”€ ยทd4f537e (๐Ÿ˜๏ธ) โ”‚ โ”œโ”€โ”€ ยทb448757 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยทe9a378d (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก:4:B on 3183e43 - โ”œโ”€โ”€ :4:B +โ””โ”€โ”€ โ‰ก:B on 3183e43 + โ”œโ”€โ”€ :B โ”‚ โ”œโ”€โ”€ ยทacdc49a (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยทf0117e0 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :7:shared + โ””โ”€โ”€ :shared โ”œโ”€โ”€ ยทd4f537e (๐Ÿ˜๏ธ) โ”œโ”€โ”€ ยทb448757 (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยทe9a378d (๐Ÿ˜๏ธ) @@ -5019,30 +5019,30 @@ fn multi_lane_with_shared_segment() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 -โ”œโ”€โ”€ โ‰ก:4:D on 3183e43 -โ”‚ โ”œโ”€โ”€ :4:D +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 +โ”œโ”€โ”€ โ‰ก:D on 3183e43 +โ”‚ โ”œโ”€โ”€ :D โ”‚ โ”‚ โ””โ”€โ”€ ยท9895054 (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ :7:C +โ”‚ โ”œโ”€โ”€ :C โ”‚ โ”‚ โ”œโ”€โ”€ ยทde625cc (๐Ÿ˜๏ธ) โ”‚ โ”‚ โ”œโ”€โ”€ ยท23419f8 (๐Ÿ˜๏ธ) โ”‚ โ”‚ โ””โ”€โ”€ ยท5dc4389 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ :3:shared +โ”‚ โ””โ”€โ”€ :shared โ”‚ โ”œโ”€โ”€ ยทd4f537e (๐Ÿ˜๏ธ) โ”‚ โ”œโ”€โ”€ ยทb448757 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยทe9a378d (๐Ÿ˜๏ธ) -โ”œโ”€โ”€ โ‰ก๐Ÿ‘‰:0:A on 3183e43 -โ”‚ โ”œโ”€โ”€ ๐Ÿ‘‰:0:A +โ”œโ”€โ”€ โ‰ก๐Ÿ‘‰:A on 3183e43 +โ”‚ โ”œโ”€โ”€ ๐Ÿ‘‰:A โ”‚ โ”‚ โ””โ”€โ”€ ยท0bad3af (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ :3:shared +โ”‚ โ””โ”€โ”€ :shared โ”‚ โ”œโ”€โ”€ ยทd4f537e (๐Ÿ˜๏ธ) โ”‚ โ”œโ”€โ”€ ยทb448757 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยทe9a378d (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก:5:B on 3183e43 - โ”œโ”€โ”€ :5:B +โ””โ”€โ”€ โ‰ก:B on 3183e43 + โ”œโ”€โ”€ :B โ”‚ โ”œโ”€โ”€ ยทacdc49a (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยทf0117e0 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :3:shared + โ””โ”€โ”€ :shared โ”œโ”€โ”€ ยทd4f537e (๐Ÿ˜๏ธ) โ”œโ”€โ”€ ยทb448757 (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยทe9a378d (๐Ÿ˜๏ธ) @@ -5129,10 +5129,10 @@ fn dependent_branch_insertion() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:dependent on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:5:dependent - โ””โ”€โ”€ ๐Ÿ“™:6:advanced-lane <> origin/advanced-lane โ†’:4: +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:dependent on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:dependent + โ””โ”€โ”€ ๐Ÿ“™:advanced-lane <> origin/advanced-lane โ””โ”€โ”€ โ„๏ธcbc6713 (๐Ÿ˜๏ธ) "#]] @@ -5176,10 +5176,10 @@ fn dependent_branch_insertion() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:advanced-lane <> origin/advanced-lane โ†’:4: on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:5:advanced-lane <> origin/advanced-lane โ†’:4: - โ””โ”€โ”€ ๐Ÿ“™:6:dependent +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:advanced-lane <> origin/advanced-lane on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:advanced-lane <> origin/advanced-lane + โ””โ”€โ”€ ๐Ÿ“™:dependent โ””โ”€โ”€ โ„cbc6713 (๐Ÿ˜๏ธ) "#]] @@ -5197,10 +5197,10 @@ fn dependent_branch_insertion() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:5:advanced-lane <> origin/advanced-lane โ†’:4: on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:5:advanced-lane <> origin/advanced-lane โ†’:4: - โ””โ”€โ”€ ๐Ÿ“™:6:dependent +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:advanced-lane <> origin/advanced-lane on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:advanced-lane <> origin/advanced-lane + โ””โ”€โ”€ ๐Ÿ“™:dependent โ””โ”€โ”€ โ„cbc6713 (๐Ÿ˜๏ธ) "#]] @@ -5218,10 +5218,10 @@ fn dependent_branch_insertion() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:advanced-lane <> origin/advanced-lane โ†’:4: on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:5:advanced-lane <> origin/advanced-lane โ†’:4: - โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:6:dependent +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:advanced-lane <> origin/advanced-lane on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:advanced-lane <> origin/advanced-lane + โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:dependent โ””โ”€โ”€ โ„cbc6713 (๐Ÿ˜๏ธ) "#]] @@ -5281,17 +5281,17 @@ fn multiple_stacks_with_shared_parent_and_remote() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:C-on-A on fafd9d0 {1} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:3:C-on-A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C-on-A on fafd9d0 {1} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C-on-A โ”‚ โ”‚ โ””โ”€โ”€ ยท4f1bb32 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ :4:A <> origin/A โ†’:5:โ‡ฃ1 +โ”‚ โ””โ”€โ”€ :A <> origin/Aโ‡ฃ1 โ”‚ โ”œโ”€โ”€ ๐ŸŸฃb627ca7 โ”‚ โ””โ”€โ”€ โ„๏ธe255adc (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก:6:B-on-A on fafd9d0 - โ”œโ”€โ”€ :6:B-on-A +โ””โ”€โ”€ โ‰ก:B-on-A on fafd9d0 + โ”œโ”€โ”€ :B-on-A โ”‚ โ””โ”€โ”€ ยทaff8449 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :4:A <> origin/A โ†’:5:โ‡ฃ1 + โ””โ”€โ”€ :A <> origin/Aโ‡ฃ1 โ”œโ”€โ”€ ๐ŸŸฃb627ca7 โ””โ”€โ”€ โ„๏ธe255adc (๐Ÿ˜๏ธ) @@ -5350,12 +5350,12 @@ fn a_stack_segment_can_be_a_segment_elsewhere_and_stack_order() -> anyhow::Resul snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:advanced-lane on fafd9d0 {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:2:advanced-lane +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:advanced-lane on fafd9d0 {0} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:advanced-lane โ”‚ โ””โ”€โ”€ ยทcbc6713 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:lane on fafd9d0 {1} - โ””โ”€โ”€ ๐Ÿ“™:4:lane +โ””โ”€โ”€ โ‰ก๐Ÿ“™:lane on fafd9d0 {1} + โ””โ”€โ”€ ๐Ÿ“™:lane "#]] ); @@ -5387,11 +5387,11 @@ fn a_stack_segment_can_be_a_segment_elsewhere_and_stack_order() -> anyhow::Resul snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:4:lane on fafd9d0 {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:lane -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:advanced-lane on fafd9d0 {1} - โ””โ”€โ”€ ๐Ÿ“™:3:advanced-lane +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:lane on fafd9d0 {0} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:lane +โ””โ”€โ”€ โ‰ก๐Ÿ“™:advanced-lane on fafd9d0 {1} + โ””โ”€โ”€ ๐Ÿ“™:advanced-lane โ””โ”€โ”€ ยทcbc6713 (๐Ÿ˜๏ธ) "#]] @@ -5456,13 +5456,13 @@ fn two_dependent_branches_with_embedded_remote() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A <> origin/A โ†’:4:โ‡ก1โ‡ฃ1 on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:3:A <> origin/A โ†’:4:โ‡ก1โ‡ฃ1 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A <> origin/Aโ‡ก1โ‡ฃ1 on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:A <> origin/Aโ‡ก1โ‡ฃ1 โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ2b1808c โ”‚ โ”œโ”€โ”€ ยทaadad9d (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยท96a2408 (๐Ÿ˜๏ธ|โœ“) - โ””โ”€โ”€ :5:integrated + โ””โ”€โ”€ :integrated โ”œโ”€โ”€ โ„f15ca75 (๐Ÿ˜๏ธ|โœ“) โ””โ”€โ”€ โ„9456d79 (๐Ÿ˜๏ธ|โœ“) @@ -5476,9 +5476,9 @@ fn two_dependent_branches_with_embedded_remote() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 96a2408 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A <> origin/A โ†’:4:โ‡ก1โ‡ฃ1 on 96a2408 {1} - โ””โ”€โ”€ ๐Ÿ“™:3:A <> origin/A โ†’:4:โ‡ก1โ‡ฃ1 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 96a2408 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A <> origin/Aโ‡ก1โ‡ฃ1 on 96a2408 {1} + โ””โ”€โ”€ ๐Ÿ“™:A <> origin/Aโ‡ก1โ‡ฃ1 โ”œโ”€โ”€ ๐ŸŸฃ2b1808c โ””โ”€โ”€ ยทaadad9d (๐Ÿ˜๏ธ) @@ -5550,12 +5550,12 @@ fn two_dependent_branches_rebased_with_remotes_merge_local() -> anyhow::Result<( snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 281456a -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:B <> origin/B โ†’:5:โ‡ก1โ‡ฃ1 on 281456a {0} - โ”œโ”€โ”€ ๐Ÿ“™:3:B <> origin/B โ†’:5:โ‡ก1โ‡ฃ1 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 281456a +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B <> origin/Bโ‡ก1โ‡ฃ1 on 281456a {0} + โ”œโ”€โ”€ ๐Ÿ“™:B <> origin/Bโ‡ก1โ‡ฃ1 โ”‚ โ”œโ”€โ”€ ๐ŸŸฃe0bd0a7 โ”‚ โ””โ”€โ”€ ยทda597e8 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:4:A <> origin/A โ†’:6:โ‡ฃ1 + โ””โ”€โ”€ ๐Ÿ“™:A <> origin/Aโ‡ฃ1 โ”œโ”€โ”€ ๐ŸŸฃ0b6b861 โ””โ”€โ”€ ยท1818c17 (๐Ÿ˜๏ธ|โœ“) @@ -5573,9 +5573,9 @@ fn two_dependent_branches_rebased_with_remotes_merge_local() -> anyhow::Result<( snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 1818c17 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B <> origin/B โ†’:6:โ‡ก1โ‡ฃ1 on 1818c17 {0} - โ””โ”€โ”€ ๐Ÿ“™:4:B <> origin/B โ†’:6:โ‡ก1โ‡ฃ1 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 1818c17 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B <> origin/Bโ‡ก1โ‡ฃ1 on 1818c17 {0} + โ””โ”€โ”€ ๐Ÿ“™:B <> origin/Bโ‡ก1โ‡ฃ1 โ”œโ”€โ”€ ๐ŸŸฃe0bd0a7 โ””โ”€โ”€ ยทda597e8 (๐Ÿ˜๏ธ) @@ -5610,11 +5610,11 @@ fn stacked_bottom_remote_still_points_at_now_split_top() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:top on fafd9d0 {0} - โ”œโ”€โ”€ ๐Ÿ“™:3:top +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:top on fafd9d0 {0} + โ”œโ”€โ”€ ๐Ÿ“™:top โ”‚ โ””โ”€โ”€ โ„bfbff44 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:4:bottom <> origin/bottom โ†’:5:โ‡ฃ1 + โ””โ”€โ”€ ๐Ÿ“™:bottom <> origin/bottomโ‡ฃ1 โ”œโ”€โ”€ ๐ŸŸฃbfbff44 (๐Ÿ˜๏ธ) โ””โ”€โ”€ โ„๏ธ7fdb58d (๐Ÿ˜๏ธ) @@ -5699,9 +5699,9 @@ fn two_dependent_branches_rebased_with_remotes_squash_merge_remote_ambiguous() - snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 0b6b861 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:D <> origin/D โ†’:4:โ‡ก1โ‡ฃ1 on 0b6b861 {0} - โ””โ”€โ”€ ๐Ÿ“™:3:D <> origin/D โ†’:4:โ‡ก1โ‡ฃ1 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 0b6b861 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:D <> origin/Dโ‡ก1โ‡ฃ1 on 0b6b861 {0} + โ””โ”€โ”€ ๐Ÿ“™:D <> origin/Dโ‡ก1โ‡ฃ1 โ”œโ”€โ”€ ๐ŸŸฃ3045ea6 โ””โ”€โ”€ ยท624e118 (๐Ÿ˜๏ธ) @@ -5775,9 +5775,9 @@ fn two_dependent_branches_rebased_with_remotes_squash_merge_remote() -> anyhow:: snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 0b6b861 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:D <> origin/D โ†’:4:โ‡ก3โ‡ฃ1 on 0b6b861 {0} - โ””โ”€โ”€ ๐Ÿ“™:3:D <> origin/D โ†’:4:โ‡ก3โ‡ฃ1 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 0b6b861 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:D <> origin/Dโ‡ก3โ‡ฃ1 on 0b6b861 {0} + โ””โ”€โ”€ ๐Ÿ“™:D <> origin/Dโ‡ก3โ‡ฃ1 โ”œโ”€โ”€ ๐ŸŸฃbbd4ff6 โ”œโ”€โ”€ ยท353471f (๐Ÿ˜๏ธ) โ”œโ”€โ”€ ยท8a4b945 (๐Ÿ˜๏ธ) @@ -5824,13 +5824,13 @@ fn without_target_ref_or_managed_commit() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:1:A <> origin/A โ†’:2:โ‡ฃ1 - โ”œโ”€โ”€ :1:A <> origin/A โ†’:2:โ‡ฃ1 +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:A <> origin/Aโ‡ฃ1 + โ”œโ”€โ”€ :A <> origin/Aโ‡ฃ1 โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ4fe5a6f โ”‚ โ”œโ”€โ”€ โ„๏ธa62b0de (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ โ„๏ธ120a217 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :3:main + โ””โ”€โ”€ :main โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) "#]] @@ -5866,13 +5866,13 @@ fn without_target_ref_or_managed_commit() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:0:A <> origin/A โ†’:2:โ‡ฃ1 - โ”œโ”€โ”€ ๐Ÿ‘‰:0:A <> origin/A โ†’:2:โ‡ฃ1 +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:A <> origin/Aโ‡ฃ1 + โ”œโ”€โ”€ ๐Ÿ‘‰:A <> origin/Aโ‡ฃ1 โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ4fe5a6f โ”‚ โ”œโ”€โ”€ โ„๏ธa62b0de (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ โ„๏ธ120a217 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :3:main + โ””โ”€โ”€ :main โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) "#]] @@ -5918,13 +5918,13 @@ fn without_target_ref_or_managed_commit_ambiguous() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:1:A <> origin/A โ†’:2:โ‡ฃ1 - โ”œโ”€โ”€ :1:A <> origin/A โ†’:2:โ‡ฃ1 +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:A <> origin/Aโ‡ฃ1 + โ”œโ”€โ”€ :A <> origin/Aโ‡ฃ1 โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ4fe5a6f โ”‚ โ”œโ”€โ”€ โ„๏ธa62b0de (๐Ÿ˜๏ธ) โ–บB โ”‚ โ””โ”€โ”€ โ„๏ธ120a217 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :3:main + โ””โ”€โ”€ :main โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) "#]] @@ -5965,14 +5965,14 @@ fn without_target_ref_or_managed_commit_ambiguous() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:4:A <> origin/A โ†’:2:โ‡ฃ1 {1} - โ”œโ”€โ”€ ๐Ÿ‘‰:4:A <> origin/A โ†’:2:โ‡ฃ1 +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:A <> origin/Aโ‡ฃ1 {1} + โ”œโ”€โ”€ ๐Ÿ‘‰:A <> origin/Aโ‡ฃ1 โ”‚ โ””โ”€โ”€ ๐ŸŸฃ4fe5a6f - โ”œโ”€โ”€ ๐Ÿ“™:0:B + โ”œโ”€โ”€ ๐Ÿ“™:B โ”‚ โ”œโ”€โ”€ โ„a62b0de (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ โ„120a217 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :3:main + โ””โ”€โ”€ :main โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) "#]] @@ -6001,12 +6001,12 @@ fn without_target_ref_or_managed_commit_ambiguous() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ“™:1:B {1} - โ”œโ”€โ”€ ๐Ÿ“™:1:B +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B {1} + โ”œโ”€โ”€ ๐Ÿ“™:B โ”‚ โ”œโ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ) โ–บA โ”‚ โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :3:main + โ””โ”€โ”€ :main โ””โ”€โ”€ ยทfafd9d0 (๐Ÿ˜๏ธ) "#]] @@ -6026,14 +6026,14 @@ fn without_target_ref_or_managed_commit_ambiguous() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B {1} - โ”œโ”€โ”€ ๐Ÿ“™:4:B - โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:5:A <> origin/A โ†’:2:โ‡ฃ1 +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B {1} + โ”œโ”€โ”€ ๐Ÿ“™:B + โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:A <> origin/Aโ‡ฃ1 โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ4fe5a6f โ”‚ โ”œโ”€โ”€ โ„๏ธa62b0de (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ โ„๏ธ120a217 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :3:main + โ””โ”€โ”€ :main โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) "#]] @@ -6047,14 +6047,14 @@ fn without_target_ref_or_managed_commit_ambiguous() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:4:A <> origin/A โ†’:2:โ‡ฃ1 {1} - โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:4:A <> origin/A โ†’:2:โ‡ฃ1 +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:A <> origin/Aโ‡ฃ1 {1} + โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:A <> origin/Aโ‡ฃ1 โ”‚ โ””โ”€โ”€ ๐ŸŸฃ4fe5a6f - โ”œโ”€โ”€ ๐Ÿ“™:5:B + โ”œโ”€โ”€ ๐Ÿ“™:B โ”‚ โ”œโ”€โ”€ โ„a62b0de (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ โ„120a217 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :3:main + โ””โ”€โ”€ :main โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) "#]] @@ -6101,12 +6101,12 @@ fn without_target_ref_or_managed_commit_ambiguous_with_remotes() -> anyhow::Resu snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:1:anon: - โ”œโ”€โ”€ :1:anon: +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:anon: + โ”œโ”€โ”€ :anon: โ”‚ โ”œโ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ) โ–บA, โ–บB โ”‚ โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :4:main <> origin/mainโ‡ก1 + โ””โ”€โ”€ :main <> origin/mainโ‡ก1 โ””โ”€โ”€ ยทfafd9d0 (๐Ÿ˜๏ธ) "#]] @@ -6142,12 +6142,12 @@ fn without_target_ref_or_managed_commit_ambiguous_with_remotes() -> anyhow::Resu snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:0:A <> origin/A โ†’:2: - โ”œโ”€โ”€ ๐Ÿ‘‰:0:A <> origin/A โ†’:2: +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:A <> origin/A + โ”œโ”€โ”€ ๐Ÿ‘‰:A <> origin/A โ”‚ โ”œโ”€โ”€ โ„๏ธa62b0de (๐Ÿ˜๏ธ) โ–บB โ”‚ โ””โ”€โ”€ โ„๏ธ120a217 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :4:main <> origin/main + โ””โ”€โ”€ :main <> origin/main โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) "#]] @@ -6161,12 +6161,12 @@ fn without_target_ref_or_managed_commit_ambiguous_with_remotes() -> anyhow::Resu snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:0:B <> origin/B โ†’:3: - โ”œโ”€โ”€ ๐Ÿ‘‰:0:B <> origin/B โ†’:3: +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:B <> origin/B + โ”œโ”€โ”€ ๐Ÿ‘‰:B <> origin/B โ”‚ โ”œโ”€โ”€ โ„๏ธa62b0de (๐Ÿ˜๏ธ) โ–บA โ”‚ โ””โ”€โ”€ โ„๏ธ120a217 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :4:main <> origin/main + โ””โ”€โ”€ :main <> origin/main โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) "#]] @@ -6211,13 +6211,13 @@ fn without_target_ref_or_managed_commit_ambiguous_with_remotes() -> anyhow::Resu snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:5:A <> origin/A โ†’:2: {1} - โ”œโ”€โ”€ ๐Ÿ‘‰:5:A <> origin/A โ†’:2: - โ”œโ”€โ”€ ๐Ÿ“™:0:B <> origin/B โ†’:3: +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:A <> origin/A {1} + โ”œโ”€โ”€ ๐Ÿ‘‰:A <> origin/A + โ”œโ”€โ”€ ๐Ÿ“™:B <> origin/B โ”‚ โ”œโ”€โ”€ โ„๏ธa62b0de (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ โ„๏ธ120a217 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :4:main <> origin/main + โ””โ”€โ”€ :main <> origin/main โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) "#]] @@ -6266,13 +6266,13 @@ fn without_target_ref_with_managed_commit() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:1:A <> origin/A โ†’:2:โ‡ฃ1 - โ”œโ”€โ”€ :1:A <> origin/A โ†’:2:โ‡ฃ1 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:A <> origin/Aโ‡ฃ1 + โ”œโ”€โ”€ :A <> origin/Aโ‡ฃ1 โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ4fe5a6f โ”‚ โ”œโ”€โ”€ โ„๏ธa62b0de (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ โ„๏ธ120a217 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :3:main + โ””โ”€โ”€ :main โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) "#]] @@ -6308,13 +6308,13 @@ fn without_target_ref_with_managed_commit() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:0:A <> origin/A โ†’:2:โ‡ฃ1 - โ”œโ”€โ”€ ๐Ÿ‘‰:0:A <> origin/A โ†’:2:โ‡ฃ1 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:A <> origin/Aโ‡ฃ1 + โ”œโ”€โ”€ ๐Ÿ‘‰:A <> origin/Aโ‡ฃ1 โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ4fe5a6f โ”‚ โ”œโ”€โ”€ โ„๏ธa62b0de (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ โ„๏ธ120a217 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :3:main + โ””โ”€โ”€ :main โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) "#]] @@ -6357,7 +6357,7 @@ fn workspace_commit_pushed_to_target() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 120a217 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 120a217 "#]] ); @@ -6401,13 +6401,13 @@ fn no_workspace_no_target_commit_under_managed_ref() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:1:anon: - โ”œโ”€โ”€ :1:anon: +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:anon: + โ”œโ”€โ”€ :anon: โ”‚ โ””โ”€โ”€ ยทdca94a4 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ :2:A + โ”œโ”€โ”€ :A โ”‚ โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :3:main + โ””โ”€โ”€ :main โ””โ”€โ”€ ยทfafd9d0 (๐Ÿ˜๏ธ) "#]] @@ -6470,16 +6470,16 @@ fn no_workspace_commit() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:lane on fafd9d0 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:3:lane +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:lane on fafd9d0 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:lane โ”‚ โ”‚ โ””โ”€โ”€ ยทcbc6713 (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:7:lane-segment-01 -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:8:lane-segment-02 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:lane-2 on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:4:lane-2 - โ”œโ”€โ”€ ๐Ÿ“™:5:lane-2-segment-01 - โ””โ”€โ”€ ๐Ÿ“™:6:lane-2-segment-02 +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:lane-segment-01 +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:lane-segment-02 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:lane-2 on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:lane-2 + โ”œโ”€โ”€ ๐Ÿ“™:lane-2-segment-01 + โ””โ”€โ”€ ๐Ÿ“™:lane-2-segment-02 "#]] ); @@ -6527,16 +6527,16 @@ fn no_workspace_commit() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:4:lane-2 on fafd9d0 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:4:lane-2 -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:5:lane-2-segment-01 -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:6:lane-2-segment-02 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:lane on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:3:lane +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:lane-2 on fafd9d0 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:lane-2 +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:lane-2-segment-01 +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:lane-2-segment-02 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:lane on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:lane โ”‚ โ””โ”€โ”€ ยทcbc6713 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ๐Ÿ“™:7:lane-segment-01 - โ””โ”€โ”€ ๐Ÿ“™:8:lane-segment-02 + โ”œโ”€โ”€ ๐Ÿ“™:lane-segment-01 + โ””โ”€โ”€ ๐Ÿ“™:lane-segment-02 "#]] ); @@ -6586,11 +6586,11 @@ fn two_dependent_branches_first_merged_by_rebase() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 281456a -โ””โ”€โ”€ โ‰ก:3:B on 281456a - โ”œโ”€โ”€ :3:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 281456a +โ””โ”€โ”€ โ‰ก:B on 281456a + โ”œโ”€โ”€ :B โ”‚ โ””โ”€โ”€ ยทda597e8 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :4:A <> origin/A โ†’:5:โ‡ก1โ‡ฃ1 + โ””โ”€โ”€ :A <> origin/Aโ‡ก1โ‡ฃ1 โ”œโ”€โ”€ ๐ŸŸฃ0b6b861 (โœ“) โ””โ”€โ”€ ยท1818c17 (๐Ÿ˜๏ธ) @@ -6637,9 +6637,9 @@ fn special_branch_names_do_not_end_up_in_segment() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:1:main - โ””โ”€โ”€ :1:main +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:main + โ””โ”€โ”€ :main โ”œโ”€โ”€ ยท3686017 (๐Ÿ˜๏ธ) โ”œโ”€โ”€ ยท9725482 (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยทfafd9d0 (๐Ÿ˜๏ธ) @@ -6709,12 +6709,12 @@ fn special_branch_do_not_allow_overly_long_segments() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/gitbutler/target on ce09734 -โ””โ”€โ”€ โ‰ก:3:A on ce09734 - โ”œโ”€โ”€ :3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/gitbutler/target on ce09734 +โ””โ”€โ”€ โ‰ก:A on ce09734 + โ”œโ”€โ”€ :A โ”‚ โ”œโ”€โ”€ ยทc59457b (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยทe146f13 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :5:main <> origin/main โ†’:6: + โ””โ”€โ”€ :main <> origin/main โ””โ”€โ”€ โ„๏ธ971953d (๐Ÿ˜๏ธ) "#]] @@ -6823,16 +6823,16 @@ fn branch_ahead_of_workspace() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ2 on fafd9d0 -โ”œโ”€โ”€ โ‰ก:6:B on fafd9d0 -โ”‚ โ””โ”€โ”€ :6:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ2 on fafd9d0 +โ”œโ”€โ”€ โ‰ก:B on fafd9d0 +โ”‚ โ””โ”€โ”€ :B โ”‚ โ””โ”€โ”€ ยท2f8f06d (๐Ÿ˜๏ธ) -โ”œโ”€โ”€ โ‰ก:7:C on fafd9d0 -โ”‚ โ””โ”€โ”€ :7:C +โ”œโ”€โ”€ โ‰ก:C on fafd9d0 +โ”‚ โ””โ”€โ”€ :C โ”‚ โ”œโ”€โ”€ ยท3f7c4e6 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยทb6895d7 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก:8:new-name-for-D on fafd9d0 - โ””โ”€โ”€ :8:new-name-for-D +โ””โ”€โ”€ โ‰ก:new-name-for-D on fafd9d0 + โ””โ”€โ”€ :new-name-for-D โ””โ”€โ”€ ยทed36e3b (๐Ÿ˜๏ธ) "#]] @@ -6924,32 +6924,32 @@ fn branch_ahead_of_workspace() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ2 on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:19:A โ†’:4: on fafd9d0 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:19:A โ†’:4: +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ2 on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A โ”‚ โ”‚ โ”œโ”€โ”€ ยทc83f258* โ”‚ โ”‚ โ””โ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ|โœ“) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:21:A-middle <> origin/A-middle โ†’:5: +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A-middle <> origin/A-middle โ”‚ โ”œโ”€โ”€ ยท27c2545* โ”‚ โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ|โœ“) -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:6:B on fafd9d0 {1} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:6:B +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on fafd9d0 {1} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B โ”‚ โ”‚ โ””โ”€โ”€ ยท2f8f06d (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:15:B-middle <> origin/B-middle โ†’:7: +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B-middle <> origin/B-middle โ”‚ โ”œโ”€โ”€ ยทc8f73c7* โ”‚ โ”œโ”€โ”€ ยทff75b80* โ”‚ โ”œโ”€โ”€ ยท91bc3fc (๐Ÿ˜๏ธ|โœ“) โ”‚ โ””โ”€โ”€ ยทcf9330f (๐Ÿ˜๏ธ|โœ“) -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:8:C on fafd9d0 {2} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:8:C +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on fafd9d0 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ”‚ โ””โ”€โ”€ ยท3f7c4e6 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:20:C-bottom โ†’:9: +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:C-bottom โ”‚ โ”œโ”€โ”€ ยท790a17d* โ”‚ โ”œโ”€โ”€ ยท969aaec* โ”‚ โ”œโ”€โ”€ ยท631be19* โ”‚ โ””โ”€โ”€ ยทb6895d7 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก:18:new-name-for-D on fafd9d0 - โ””โ”€โ”€ :18:new-name-for-D +โ””โ”€โ”€ โ‰ก:new-name-for-D on fafd9d0 + โ””โ”€โ”€ :new-name-for-D โ””โ”€โ”€ ยทed36e3b (๐Ÿ˜๏ธ) "#]] @@ -7009,11 +7009,11 @@ fn two_branches_one_advanced_two_parent_ws_commit_diverged_ttb() -> anyhow::Resu snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:4:lane on fafd9d0 {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:4:lane -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:advanced-lane on fafd9d0 {1} - โ””โ”€โ”€ ๐Ÿ“™:3:advanced-lane +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:lane on fafd9d0 {0} +โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:lane +โ””โ”€โ”€ โ‰ก๐Ÿ“™:advanced-lane on fafd9d0 {1} + โ””โ”€โ”€ ๐Ÿ“™:advanced-lane โ””โ”€โ”€ ยทcbc6713 (๐Ÿ˜๏ธ) "#]] @@ -7047,11 +7047,11 @@ fn two_branches_one_advanced_two_parent_ws_commit_diverged_ttb() -> anyhow::Resu snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:4:lane on fafd9d0 {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:lane -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:advanced-lane on fafd9d0 {1} - โ””โ”€โ”€ ๐Ÿ“™:3:advanced-lane +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:lane on fafd9d0 {0} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:lane +โ””โ”€โ”€ โ‰ก๐Ÿ“™:advanced-lane on fafd9d0 {1} + โ””โ”€โ”€ ๐Ÿ“™:advanced-lane โ””โ”€โ”€ ยทcbc6713 (๐Ÿ˜๏ธ) "#]] @@ -7080,11 +7080,11 @@ fn two_branches_one_advanced_two_parent_ws_commit_diverged_ttb() -> anyhow::Resu snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:4:lane on fafd9d0 {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:lane -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:advanced-lane on fafd9d0 {1} - โ””โ”€โ”€ ๐Ÿ“™:3:advanced-lane +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:lane on fafd9d0 {0} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:lane +โ””โ”€โ”€ โ‰ก๐Ÿ“™:advanced-lane on fafd9d0 {1} + โ””โ”€โ”€ ๐Ÿ“™:advanced-lane โ””โ”€โ”€ ยทcbc6713 (๐Ÿ˜๏ธ) "#]] @@ -7160,16 +7160,16 @@ fn advanced_workspace_ref() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ””โ”€โ”€ โ‰ก:5:anon: on bce0c5e {1} - โ”œโ”€โ”€ :5:anon: +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ””โ”€โ”€ โ‰ก:anon: on bce0c5e {1} + โ”œโ”€โ”€ :anon: โ”‚ โ””โ”€โ”€ ยทa7131b1 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ :6:intermediate-ref + โ”œโ”€โ”€ :intermediate-ref โ”‚ โ”œโ”€โ”€ ยท4d3831e (๐Ÿ˜๏ธ) โ”‚ โ”œโ”€โ”€ ยท468357f (๐Ÿ˜๏ธ) โ”‚ โ”œโ”€โ”€ ยท118ddbb (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยท619d548 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:4:B + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยท8a352d5 (๐Ÿ˜๏ธ) "#]] @@ -7218,16 +7218,16 @@ fn advanced_workspace_ref() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ””โ”€โ”€ โ‰ก:5:anon: on bce0c5e {1} - โ”œโ”€โ”€ :5:anon: +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ””โ”€โ”€ โ‰ก:anon: on bce0c5e {1} + โ”œโ”€โ”€ :anon: โ”‚ โ””โ”€โ”€ ยทa7131b1 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ :6:intermediate-ref + โ”œโ”€โ”€ :intermediate-ref โ”‚ โ”œโ”€โ”€ ยท4d3831e (๐Ÿ˜๏ธ) โ”‚ โ”œโ”€โ”€ ยท468357f (๐Ÿ˜๏ธ) โ”‚ โ”œโ”€โ”€ ยท118ddbb (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยท619d548 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:4:B + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยท8a352d5 (๐Ÿ˜๏ธ) "#]] @@ -7295,16 +7295,16 @@ fn advanced_workspace_ref_single_stack() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ””โ”€โ”€ โ‰ก:4:anon: on bce0c5e {0} - โ”œโ”€โ”€ :4:anon: +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ””โ”€โ”€ โ‰ก:anon: on bce0c5e {0} + โ”œโ”€โ”€ :anon: โ”‚ โ””โ”€โ”€ ยทda912a8 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ :5:intermediate-ref + โ”œโ”€โ”€ :intermediate-ref โ”‚ โ”œโ”€โ”€ ยท198eaf8 (๐Ÿ˜๏ธ) โ”‚ โ”œโ”€โ”€ ยท3147997 (๐Ÿ˜๏ธ) โ”‚ โ”œโ”€โ”€ ยท9785229 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยทc58f157 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:3:A + โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) "#]] @@ -7356,9 +7356,9 @@ fn shallow_boundary_below_workspace_lower_bound() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on b625665 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A <> origin/A โ†’:4: on b625665 {1} - โ””โ”€โ”€ ๐Ÿ“™:3:A <> origin/A โ†’:4: +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on b625665 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A <> origin/A on b625665 {1} + โ””โ”€โ”€ ๐Ÿ“™:A <> origin/A โ””โ”€โ”€ โ„๏ธ6507810 (๐Ÿ˜๏ธ) "#]] @@ -7391,9 +7391,9 @@ fn shallow_boundary_in_workspace_prevents_lower_bound() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ“™:1:A {1} - โ””โ”€โ”€ ๐Ÿ“™:1:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A {1} + โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ ยท6507810 (๐Ÿ˜๏ธ|โ›ฐ) "#]] @@ -7451,13 +7451,13 @@ fn applied_stack_below_explicit_lower_bound() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on bce0c5e -โ”œโ”€โ”€ โ‰ก:1:B on bce0c5e -โ”‚ โ””โ”€โ”€ :1:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on bce0c5e +โ”œโ”€โ”€ โ‰ก:B on bce0c5e +โ”‚ โ””โ”€โ”€ :B โ”‚ โ”œโ”€โ”€ ยท78b1b59 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยทf52fcec (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก:2:A on bce0c5e - โ””โ”€โ”€ :2:A +โ””โ”€โ”€ โ‰ก:A on bce0c5e + โ””โ”€โ”€ :A โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) "#]] @@ -7495,12 +7495,12 @@ fn applied_stack_below_explicit_lower_bound() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on bce0c5e -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on bce0c5e {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on bce0c5e +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on bce0c5e {0} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on bce0c5e {1} - โ””โ”€โ”€ ๐Ÿ“™:4:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on bce0c5e {1} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยท78b1b59 (๐Ÿ˜๏ธ) "#]] @@ -7544,12 +7544,12 @@ fn applied_stack_below_explicit_lower_bound() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on bce0c5e -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:4:A on bce0c5e {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on bce0c5e +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on bce0c5e {0} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:B on f52fcec {1} - โ””โ”€โ”€ ๐Ÿ“™:5:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on f52fcec {1} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยท78b1b59 (๐Ÿ˜๏ธ) "#]] @@ -7608,14 +7608,14 @@ fn applied_stack_above_explicit_lower_bound() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on bce0c5e -โ”œโ”€โ”€ โ‰ก:1:B on bce0c5e -โ”‚ โ””โ”€โ”€ :1:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on bce0c5e +โ”œโ”€โ”€ โ‰ก:B on bce0c5e +โ”‚ โ””โ”€โ”€ :B โ”‚ โ””โ”€โ”€ ยทce25240 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก:2:A on bce0c5e - โ”œโ”€โ”€ :2:A +โ””โ”€โ”€ โ‰ก:A on bce0c5e + โ”œโ”€โ”€ :A โ”‚ โ””โ”€โ”€ ยทde6d39c (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :3:main <> origin/main โ†’:4: + โ””โ”€โ”€ :main <> origin/main โ””โ”€โ”€ โ„๏ธa821094 (๐Ÿ˜๏ธ) "#]] @@ -7711,28 +7711,28 @@ fn dependent_branch_on_base() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 3183e43 {1} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {1} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A โ”‚ โ”‚ โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:18:below-A -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:19:below-below-A -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:6:B on 3183e43 {2} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:6:B -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:7:below-B -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:8:below-below-B -โ””โ”€โ”€ โ‰ก๐Ÿ“™:9:C on 3183e43 {3} - โ”œโ”€โ”€ ๐Ÿ“™:9:C - โ”œโ”€โ”€ ๐Ÿ“™:10:C2-1 - โ”œโ”€โ”€ ๐Ÿ“™:11:C2-2 - โ”œโ”€โ”€ ๐Ÿ“™:12:C2-3 +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:below-below-A +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:below-below-B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 3183e43 {3} + โ”œโ”€โ”€ ๐Ÿ“™:C + โ”œโ”€โ”€ ๐Ÿ“™:C2-1 + โ”œโ”€โ”€ ๐Ÿ“™:C2-2 + โ”œโ”€โ”€ ๐Ÿ“™:C2-3 โ”‚ โ””โ”€โ”€ ยทf9e2cb7 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ๐Ÿ“™:13:C1-3 - โ”œโ”€โ”€ ๐Ÿ“™:14:C1-2 - โ”œโ”€โ”€ ๐Ÿ“™:15:C1-1 + โ”œโ”€โ”€ ๐Ÿ“™:C1-3 + โ”œโ”€โ”€ ๐Ÿ“™:C1-2 + โ”œโ”€โ”€ ๐Ÿ“™:C1-1 โ”‚ โ””โ”€โ”€ ยทaaa195b (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ๐Ÿ“™:16:below-C - โ””โ”€โ”€ ๐Ÿ“™:17:below-below-C + โ”œโ”€โ”€ ๐Ÿ“™:below-C + โ””โ”€โ”€ ๐Ÿ“™:below-below-C "#]] ); @@ -7754,25 +7754,25 @@ fn dependent_branch_on_base() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:6:B on 3183e43 {2} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:6:B -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:7:below-B -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:8:below-below-B -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:9:C on 3183e43 {3} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:9:C -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:10:C2-1 -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:11:C2-2 -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:12:C2-3 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:below-below-B +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 3183e43 {3} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C2-1 +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C2-2 +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C2-3 โ”‚ โ”‚ โ””โ”€โ”€ ยทf9e2cb7 (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:13:C1-3 -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:14:C1-2 -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:15:C1-1 +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C1-3 +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C1-2 +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C1-1 โ”‚ โ”‚ โ””โ”€โ”€ ยทaaa195b (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:16:below-C -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:17:below-below-C -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:A on 3183e43 {1} - โ””โ”€โ”€ ๐Ÿ“™:5:A +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-C +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:below-below-C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {1} + โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) "#]] @@ -7814,9 +7814,9 @@ fn remote_and_integrated_tracking_branch_on_merge() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 1ee1e34 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:8:A <> origin/A โ†’:4:โ‡ฃ1 on 1ee1e34 {1} - โ””โ”€โ”€ ๐Ÿ“™:8:A <> origin/A โ†’:4:โ‡ฃ1 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 1ee1e34 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A <> origin/Aโ‡ฃ1 on 1ee1e34 {1} + โ””โ”€โ”€ ๐Ÿ“™:A <> origin/Aโ‡ฃ1 โ””โ”€โ”€ ๐ŸŸฃ2181501 "#]] @@ -7854,9 +7854,9 @@ fn remote_and_integrated_tracking_branch_on_linear_segment() -> anyhow::Result<( snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 081bae9 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:A <> origin/A โ†’:4:โ‡ฃ1 on 081bae9 {1} - โ””โ”€โ”€ ๐Ÿ“™:5:A <> origin/A โ†’:4:โ‡ฃ1 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 081bae9 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A <> origin/Aโ‡ฃ1 on 081bae9 {1} + โ””โ”€โ”€ ๐Ÿ“™:A <> origin/Aโ‡ฃ1 โ””โ”€โ”€ ๐ŸŸฃ197ddce "#]] @@ -7900,9 +7900,9 @@ fn remote_and_integrated_tracking_branch_on_merge_extra_target() -> anyhow::Resu snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 1ee1e34 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A <> origin/A โ†’:4:โ‡ก1โ‡ฃ1 on 1ee1e34 {1} - โ””โ”€โ”€ ๐Ÿ“™:3:A <> origin/A โ†’:4:โ‡ก1โ‡ฃ1 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 1ee1e34 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A <> origin/Aโ‡ก1โ‡ฃ1 on 1ee1e34 {1} + โ””โ”€โ”€ ๐Ÿ“™:A <> origin/Aโ‡ก1โ‡ฃ1 โ”œโ”€โ”€ ๐ŸŸฃ2181501 โ””โ”€โ”€ ยท9f47a25 (๐Ÿ˜๏ธ) @@ -7945,7 +7945,7 @@ fn unapplied_branch_on_base() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 "#]] ); @@ -7957,9 +7957,9 @@ fn unapplied_branch_on_base() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:unapplied on fafd9d0 {1} - โ””โ”€โ”€ ๐Ÿ“™:3:unapplied +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:unapplied on fafd9d0 {1} + โ””โ”€โ”€ ๐Ÿ“™:unapplied "#]] ); @@ -7974,7 +7974,7 @@ fn unapplied_branch_on_base() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 "#]] ); @@ -8041,9 +8041,9 @@ fn shared_target_base_keeps_exact_target_segment_with_inactive_unapplied_branch( snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on ce09734 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:survivor on ce09734 {1} - โ””โ”€โ”€ ๐Ÿ“™:3:survivor +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on ce09734 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:survivor on ce09734 {1} + โ””โ”€โ”€ ๐Ÿ“™:survivor โ”œโ”€โ”€ ยท4ca0966 (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยทa3b180e (๐Ÿ˜๏ธ) @@ -8065,13 +8065,13 @@ fn shared_target_base_keeps_exact_target_segment_with_inactive_unapplied_branch( snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on ce09734 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:survivor on ce09734 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:survivor +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on ce09734 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:survivor on ce09734 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:survivor โ”‚ โ”œโ”€โ”€ ยท4ca0966 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยทa3b180e (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:unapplied on ce09734 {2} - โ””โ”€โ”€ ๐Ÿ“™:4:unapplied +โ””โ”€โ”€ โ‰ก๐Ÿ“™:unapplied on ce09734 {2} + โ””โ”€โ”€ ๐Ÿ“™:unapplied "#]] ); @@ -8113,9 +8113,9 @@ fn unapplied_branch_on_base_no_target() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:2:main <> origin/main โ†’:1: - โ””โ”€โ”€ :2:main <> origin/main โ†’:1: +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:main <> origin/main + โ””โ”€โ”€ :main <> origin/main โ””โ”€โ”€ โ„๏ธfafd9d0 (๐Ÿ˜๏ธ) โ–บunapplied "#]] @@ -8147,11 +8147,11 @@ fn unapplied_branch_on_base_no_target() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:unapplied on fafd9d0 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:unapplied -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:main <> origin/main โ†’:1: on fafd9d0 {2} - โ””โ”€โ”€ ๐Ÿ“™:4:main <> origin/main โ†’:1: +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:unapplied on fafd9d0 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:unapplied +โ””โ”€โ”€ โ‰ก๐Ÿ“™:main <> origin/main on fafd9d0 {2} + โ””โ”€โ”€ ๐Ÿ“™:main <> origin/main "#]] ); @@ -8166,9 +8166,9 @@ fn unapplied_branch_on_base_no_target() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:main <> origin/main โ†’:1: on fafd9d0 {2} - โ””โ”€โ”€ ๐Ÿ“™:3:main <> origin/main โ†’:1: +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:main <> origin/main on fafd9d0 {2} + โ””โ”€โ”€ ๐Ÿ“™:main <> origin/main "#]] ); @@ -8213,11 +8213,11 @@ fn no_ws_commit_two_branches_no_target() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:main <> origin/main โ†’:1: {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:main <> origin/main โ†’:1: -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:A {1} - โ””โ”€โ”€ ๐Ÿ“™:4:A +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:main <> origin/main {0} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:main <> origin/main +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A {1} + โ””โ”€โ”€ ๐Ÿ“™:A "#]] ); @@ -8275,12 +8275,12 @@ fn ambiguous_worktrees() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ@repo] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 081bae9 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:6:A <> origin/A โ†’:4:โ‡ฃ1 on 081bae9 {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:6:A <> origin/A โ†’:4:โ‡ฃ1 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ@repo] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 081bae9 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A <> origin/Aโ‡ฃ1 on 081bae9 {0} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A <> origin/Aโ‡ฃ1 โ”‚ โ””โ”€โ”€ ๐ŸŸฃ197ddce -โ””โ”€โ”€ โ‰ก:5:B[๐Ÿ“wt-B-inside] on 081bae9 - โ””โ”€โ”€ :5:B[๐Ÿ“wt-B-inside] +โ””โ”€โ”€ โ‰ก:B[๐Ÿ“wt-B-inside] on 081bae9 + โ””โ”€โ”€ :B[๐Ÿ“wt-B-inside] โ””โ”€โ”€ ยท3e01e28 (๐Ÿ˜๏ธ) "#]] @@ -8330,12 +8330,12 @@ fn ambiguous_worktrees() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 081bae9 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:6:A <> origin/A โ†’:5:โ‡ฃ1 on 081bae9 {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:6:A <> origin/A โ†’:5:โ‡ฃ1 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 081bae9 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A <> origin/Aโ‡ฃ1 on 081bae9 {0} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A <> origin/Aโ‡ฃ1 โ”‚ โ””โ”€โ”€ ๐ŸŸฃ197ddce -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:0:B[๐Ÿ“wt-B-inside@repo] on 081bae9 - โ””โ”€โ”€ ๐Ÿ‘‰:0:B[๐Ÿ“wt-B-inside@repo] +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:B[๐Ÿ“wt-B-inside@repo] on 081bae9 + โ””โ”€โ”€ ๐Ÿ‘‰:B[๐Ÿ“wt-B-inside@repo] โ””โ”€โ”€ ยท3e01e28 (๐Ÿ˜๏ธ) "#]] @@ -8384,9 +8384,9 @@ fn duplicate_parent_connection_from_ws_commit_to_ambiguous_branch_no_advanced_ta snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on fafd9d0 {1} - โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} + โ””โ”€โ”€ ๐Ÿ“™:A "#]] ); @@ -8400,11 +8400,11 @@ fn duplicate_parent_connection_from_ws_commit_to_ambiguous_branch_no_advanced_ta snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on fafd9d0 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on fafd9d0 {2} - โ””โ”€โ”€ ๐Ÿ“™:4:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on fafd9d0 {2} + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -8419,10 +8419,10 @@ fn duplicate_parent_connection_from_ws_commit_to_ambiguous_branch_no_advanced_ta snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:3:A - โ””โ”€โ”€ ๐Ÿ“™:4:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -8472,9 +8472,9 @@ fn duplicate_parent_connection_from_ws_commit_to_ambiguous_branch() -> anyhow::R snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on fafd9d0 {1} - โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} + โ””โ”€โ”€ ๐Ÿ“™:A "#]] ); @@ -8488,11 +8488,11 @@ fn duplicate_parent_connection_from_ws_commit_to_ambiguous_branch() -> anyhow::R snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on fafd9d0 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on fafd9d0 {2} - โ””โ”€โ”€ ๐Ÿ“™:4:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on fafd9d0 {2} + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -8507,10 +8507,10 @@ fn duplicate_parent_connection_from_ws_commit_to_ambiguous_branch() -> anyhow::R snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:3:A - โ””โ”€โ”€ ๐Ÿ“™:4:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -8528,11 +8528,11 @@ fn duplicate_parent_connection_from_ws_commit_to_ambiguous_branch() -> anyhow::R snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on fafd9d0 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on fafd9d0 {2} - โ””โ”€โ”€ ๐Ÿ“™:4:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on fafd9d0 {2} + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -8548,10 +8548,10 @@ fn duplicate_parent_connection_from_ws_commit_to_ambiguous_branch() -> anyhow::R snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:3:A - โ””โ”€โ”€ ๐Ÿ“™:4:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -8605,9 +8605,9 @@ mod edit_commit { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:3:A on fafd9d0 - โ””โ”€โ”€ :3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:A on fafd9d0 + โ””โ”€โ”€ :A โ”œโ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) @@ -8645,11 +8645,11 @@ mod edit_commit { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:4:A on fafd9d0 - โ”œโ”€โ”€ :4:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:A on fafd9d0 + โ”œโ”€โ”€ :A โ”‚ โ””โ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ‘‰:0:gitbutler/edit + โ””โ”€โ”€ ๐Ÿ‘‰:gitbutler/edit โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) "#]] @@ -8706,9 +8706,9 @@ fn complex_merge_history_with_origin_main_target() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ10 on 68e62aa -โ””โ”€โ”€ โ‰ก:12:anon: on 68e62aa - โ””โ”€โ”€ :12:anon: +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ10 on 68e62aa +โ””โ”€โ”€ โ‰ก:anon: on 68e62aa + โ””โ”€โ”€ :anon: โ”œโ”€โ”€ ยท4eaff93 (๐Ÿ˜๏ธ) โ–บlocal-stack, โ–บreconstructed-insert-blank-commit-branch, โ–บreimplement-insert-blank-commit โ”œโ”€โ”€ ยทd19db1d (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยทfb0a67e (๐Ÿ˜๏ธ) @@ -8730,10 +8730,10 @@ fn complex_merge_history_with_origin_main_target() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ10 on 68e62aa -โ””โ”€โ”€ โ‰ก๐Ÿ“™:13:reimplement-insert-blank-commit on 68e62aa {0} - โ”œโ”€โ”€ ๐Ÿ“™:13:reimplement-insert-blank-commit - โ””โ”€โ”€ ๐Ÿ“™:14:reconstructed-insert-blank-commit-branch +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ10 on 68e62aa +โ””โ”€โ”€ โ‰ก๐Ÿ“™:reimplement-insert-blank-commit on 68e62aa {0} + โ”œโ”€โ”€ ๐Ÿ“™:reimplement-insert-blank-commit + โ””โ”€โ”€ ๐Ÿ“™:reconstructed-insert-blank-commit-branch โ”œโ”€โ”€ ยท4eaff93 (๐Ÿ˜๏ธ) โ–บlocal-stack โ”œโ”€โ”€ ยทd19db1d (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยทfb0a67e (๐Ÿ˜๏ธ) @@ -8792,12 +8792,12 @@ fn reproduce_12146() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e32cf47 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:5:A on e32cf47 {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:5:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e32cf47 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on e32cf47 {0} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท81d4e38 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on e32cf47 {1} - โ””โ”€โ”€ ๐Ÿ“™:4:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on e32cf47 {1} + โ””โ”€โ”€ ๐Ÿ“™:B โ”œโ”€โ”€ ยท7163661 (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยท81d4e38 (๐Ÿ˜๏ธ) @@ -8840,9 +8840,9 @@ fn integrated_merge_at_bottom_is_kept() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on f5f42e0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:local-stack {0} - โ””โ”€โ”€ ๐Ÿ“™:3:local-stack +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on f5f42e0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:local-stack {0} + โ””โ”€โ”€ ๐Ÿ“™:local-stack โ”œโ”€โ”€ ยท66ea651 (๐Ÿ˜๏ธ) โ”œโ”€โ”€ ยทe5a88a7 (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยท0b3ccaf (๐Ÿ˜๏ธ) @@ -8908,9 +8908,9 @@ fn merge_from_main_keeps_all_branch_commits() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on ef56fab -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:my-branch {0} - โ””โ”€โ”€ ๐Ÿ“™:3:my-branch +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on ef56fab +โ””โ”€โ”€ โ‰ก๐Ÿ“™:my-branch {0} + โ””โ”€โ”€ ๐Ÿ“™:my-branch โ”œโ”€โ”€ ยทcd76046 (๐Ÿ˜๏ธ) โ”œโ”€โ”€ ยทf8ff9a3 (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยท6f65768 (๐Ÿ˜๏ธ) @@ -8956,9 +8956,9 @@ fn integrated_commits_above_target_are_kept() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:my-branch on fafd9d0 {0} - โ””โ”€โ”€ ๐Ÿ“™:4:my-branch +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:my-branch on fafd9d0 {0} + โ””โ”€โ”€ ๐Ÿ“™:my-branch โ”œโ”€โ”€ ยท312f819 (๐Ÿ˜๏ธ|โœ“) โ””โ”€โ”€ ยทe255adc (๐Ÿ˜๏ธ|โœ“) @@ -8976,9 +8976,9 @@ fn integrated_commits_above_target_are_kept() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 312f819 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:my-branch on 312f819 {0} - โ””โ”€โ”€ ๐Ÿ“™:5:my-branch +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 312f819 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:my-branch on 312f819 {0} + โ””โ”€โ”€ ๐Ÿ“™:my-branch "#]] ); @@ -9039,12 +9039,12 @@ fn integrated_commits_below_target_pruned_when_upstream_ahead() -> anyhow::Resul snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 322cb14 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:4:my-branch on 2121f9c {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:my-branch +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 322cb14 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:my-branch on 2121f9c {0} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:my-branch โ”‚ โ””โ”€โ”€ ยทf5055a1 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:old-branch on 322cb14 {1} - โ””โ”€โ”€ ๐Ÿ“™:5:old-branch +โ””โ”€โ”€ โ‰ก๐Ÿ“™:old-branch on 322cb14 {1} + โ””โ”€โ”€ ๐Ÿ“™:old-branch โ””โ”€โ”€ ยทf458f7d (๐Ÿ˜๏ธ) "#]] @@ -9089,9 +9089,9 @@ fn catchup_merge_below_target_floors_at_fork() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on d263f88 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:X on b4bd43f {0} - โ””โ”€โ”€ ๐Ÿ“™:4:X +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on d263f88 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:X on b4bd43f {0} + โ””โ”€โ”€ ๐Ÿ“™:X โ”œโ”€โ”€ ยทf210f41 (๐Ÿ˜๏ธ) โ”œโ”€โ”€ ยทf8cd0ce (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยท4eec82a (๐Ÿ˜๏ธ) @@ -9142,9 +9142,9 @@ fn entrypoint_on_workspace_commit() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:3:A on fafd9d0 - โ””โ”€โ”€ :3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:A on fafd9d0 + โ””โ”€โ”€ :A โ”œโ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) @@ -9177,9 +9177,9 @@ fn entrypoint_on_workspace_commit() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:4:A on fafd9d0 - โ””โ”€โ”€ :4:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:A on fafd9d0 + โ””โ”€โ”€ :A โ”œโ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) @@ -9228,9 +9228,9 @@ fn remote_only_stack_top() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:3:anon: on fafd9d0 - โ””โ”€โ”€ :3:anon: +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:anon: on fafd9d0 + โ””โ”€โ”€ :anon: โ”œโ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) @@ -9281,7 +9281,7 @@ fn remote_trailing_local_stack() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on cb7021b +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on cb7021b "#]] ); @@ -9332,9 +9332,9 @@ fn remote_ref_as_stack_top() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:3:anon: on fafd9d0 - โ””โ”€โ”€ :3:anon: +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:anon: on fafd9d0 + โ””โ”€โ”€ :anon: โ”œโ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) diff --git a/crates/but-rebase/tests/rebase/graph_rebase/rebase_identities.rs b/crates/but-rebase/tests/rebase/graph_rebase/rebase_identities.rs index 46d426c9308..80f63903115 100644 --- a/crates/but-rebase/tests/rebase/graph_rebase/rebase_identities.rs +++ b/crates/but-rebase/tests/rebase/graph_rebase/rebase_identities.rs @@ -93,9 +93,9 @@ fn four_commits_with_short_traversal() -> Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ] +โŒ‚:main[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] {1} + โ””โ”€โ”€ :main[๐ŸŒณ] โ”œโ”€โ”€ ยท120e3a9 โ”œโ”€โ”€ ยทa96434e โ”œโ”€โ”€ ยทd591dfe diff --git a/crates/but-workspace/src/ref_info.rs b/crates/but-workspace/src/ref_info.rs index 2ad661085ab..879bbc45e85 100644 --- a/crates/but-workspace/src/ref_info.rs +++ b/crates/but-workspace/src/ref_info.rs @@ -363,7 +363,8 @@ impl std::fmt::Debug for Segment { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let Segment { ref_info, - id, + // Segment indices are ephemeral; keep them out of snapshot output. + id: _, commits, commits_on_remote, commits_outside, @@ -378,7 +379,6 @@ impl std::fmt::Debug for Segment { "{ep}ref_info::ui::Segment", ep = if *is_entrypoint { "๐Ÿ‘‰" } else { "" } )) - .field("id", &id) .field( "ref_name", &match ref_info.as_ref() { diff --git a/crates/but-workspace/tests/workspace/branch/apply_unapply.rs b/crates/but-workspace/tests/workspace/branch/apply_unapply.rs index 2ff3298e352..514eb959bda 100644 --- a/crates/but-workspace/tests/workspace/branch/apply_unapply.rs +++ b/crates/but-workspace/tests/workspace/branch/apply_unapply.rs @@ -78,9 +78,9 @@ fn operation_denied_on_improper_workspace() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 -โ””โ”€โ”€ โ‰ก:2:anon: on 3183e43 - โ””โ”€โ”€ :2:anon: +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 +โ””โ”€โ”€ โ‰ก:anon: on 3183e43 + โ””โ”€โ”€ :anon: โ”œโ”€โ”€ ยท0d01196 (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยท4979833 (๐Ÿ˜๏ธ) @@ -147,9 +147,9 @@ fn unapply_tip_of_ad_hoc_branch_is_an_error() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ] +โŒ‚:main[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] {1} + โ””โ”€โ”€ :main[๐ŸŒณ] โ”œโ”€โ”€ ยท281da94 โ”œโ”€โ”€ ยท12995d7 โ””โ”€โ”€ ยท3d57fc1 @@ -202,13 +202,13 @@ fn unapply_branch_from_named_ad_hoc_workspace_affects_metadata() -> anyhow::Resu snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:A2 <> โœ“! -โ””โ”€โ”€ โ‰ก:0:A2 {1} - โ”œโ”€โ”€ :0:A2 +โŒ‚:A2 <> โœ“! +โ””โ”€โ”€ โ‰ก:A2 {1} + โ”œโ”€โ”€ :A2 โ”‚ โ””โ”€โ”€ ยทf1889e7 - โ”œโ”€โ”€ :1:A1 + โ”œโ”€โ”€ :A1 โ”‚ โ””โ”€โ”€ ยท7de99e1 - โ””โ”€โ”€ :2:main[๐ŸŒณ] + โ””โ”€โ”€ :main[๐ŸŒณ] โ””โ”€โ”€ ยท3183e43 "#]] @@ -233,13 +233,13 @@ fn unapply_branch_from_named_ad_hoc_workspace_affects_metadata() -> anyhow::Resu snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:A2 <> โœ“! -โ””โ”€โ”€ โ‰ก:0:A2 {1} - โ”œโ”€โ”€ :0:A2 +โŒ‚:A2 <> โœ“! +โ””โ”€โ”€ โ‰ก:A2 {1} + โ”œโ”€โ”€ :A2 โ”‚ โ””โ”€โ”€ ยทf1889e7 - โ”œโ”€โ”€ ๐Ÿ“™:1:on-A1 + โ”œโ”€โ”€ ๐Ÿ“™:on-A1 โ”‚ โ””โ”€โ”€ ยท7de99e1 โ–บA1 - โ””โ”€โ”€ :2:main[๐ŸŒณ] + โ””โ”€โ”€ :main[๐ŸŒณ] โ””โ”€โ”€ ยท3183e43 "#]] @@ -258,12 +258,12 @@ fn unapply_branch_from_named_ad_hoc_workspace_affects_metadata() -> anyhow::Resu snapbox::assert_data_eq!( graph_workspace(&out.workspace).to_string(), snapbox::str![[r#" -โŒ‚:0:A2 <> โœ“! -โ””โ”€โ”€ โ‰ก:0:A2 {1} - โ”œโ”€โ”€ :0:A2 +โŒ‚:A2 <> โœ“! +โ””โ”€โ”€ โ‰ก:A2 {1} + โ”œโ”€โ”€ :A2 โ”‚ โ”œโ”€โ”€ ยทf1889e7 โ”‚ โ””โ”€โ”€ ยท7de99e1 โ–บA1, โ–บon-A1 - โ””โ”€โ”€ :1:main[๐ŸŒณ] + โ””โ”€โ”€ :main[๐ŸŒณ] โ””โ”€โ”€ ยท3183e43 "#]] @@ -307,7 +307,7 @@ fn ws_ref_no_ws_commit_two_virtual_stacks_on_same_commit_apply_dependent_first() snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 "#]] ); @@ -330,9 +330,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:2:B on e5d0542 {1} - โ””โ”€โ”€ ๐Ÿ“™:2:B +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on e5d0542 {1} + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -344,11 +344,11 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&out.workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:B on e5d0542 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:2:B -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on e5d0542 {41} - โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on e5d0542 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on e5d0542 {41} + โ””โ”€โ”€ ๐Ÿ“™:A "#]] ); @@ -385,9 +385,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:2:B on e5d0542 {1} - โ””โ”€โ”€ ๐Ÿ“™:2:B +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on e5d0542 {1} + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -414,7 +414,7 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&out.workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 "#]] ); @@ -456,11 +456,11 @@ mod workspace_disposition { snapbox::assert_data_eq!( graph_workspace(&out.workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:4:virtual-base on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:virtual-base -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:B on 85efbe4 {3} - โ””โ”€โ”€ ๐Ÿ“™:3:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:virtual-base on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:virtual-base +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {3} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -501,11 +501,11 @@ mod workspace_disposition { snapbox::assert_data_eq!( graph_workspace(&out.workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:4:virtual-base on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:virtual-base -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:B on 85efbe4 {3} - โ””โ”€โ”€ ๐Ÿ“™:3:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:virtual-base on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:virtual-base +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {3} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -574,9 +574,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&out.workspace).to_string(), snapbox::str![[r#" -โŒ‚:0:B[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:0:B[๐ŸŒณ] on 85efbe4 {1} - โ””โ”€โ”€ ๐Ÿ“™:0:B[๐ŸŒณ] +โŒ‚:B[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B[๐ŸŒณ] on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:B[๐ŸŒณ] โ””โ”€โ”€ ยทc813d8d "#]] @@ -620,12 +620,12 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&out.workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {2} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {2} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 85efbe4 {3} - โ””โ”€โ”€ ๐Ÿ“™:4:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {3} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -668,9 +668,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e5d0542 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on e5d0542 {41} - โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e5d0542 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on e5d0542 {41} + โ””โ”€โ”€ ๐Ÿ“™:A "#]] ); @@ -700,9 +700,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&out.workspace).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e5d0542 -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] <> origin/main โ†’:1: {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ] <> origin/main โ†’:1: +โŒ‚:main[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e5d0542 +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] <> origin/main {1} + โ””โ”€โ”€ :main[๐ŸŒณ] <> origin/main "#]] ); @@ -755,9 +755,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&out.workspace).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e5d0542 -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] <> origin/main โ†’:1: {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ] <> origin/main โ†’:1: +โŒ‚:main[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e5d0542 +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] <> origin/main {1} + โ””โ”€โ”€ :main[๐ŸŒณ] <> origin/main "#]] ); @@ -787,9 +787,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e5d0542 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on e5d0542 {41} - โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e5d0542 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on e5d0542 {41} + โ””โ”€โ”€ ๐Ÿ“™:A "#]] ); @@ -812,9 +812,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e5d0542 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on e5d0542 {41} - โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e5d0542 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on e5d0542 {41} + โ””โ”€โ”€ ๐Ÿ“™:A "#]] ); @@ -851,11 +851,11 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:B on 85efbe4 {1} - โ”œโ”€โ”€ ๐Ÿ“™:3:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {1} + โ”œโ”€โ”€ ๐Ÿ“™:B โ”‚ โ””โ”€โ”€ ยทd69fe94 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:4:A + โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] @@ -876,7 +876,7 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&out.workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 "#]] ); @@ -946,14 +946,14 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:5:virtual-base on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:5:virtual-base -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {2} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:virtual-base on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:virtual-base +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {2} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 85efbe4 {3} - โ””โ”€โ”€ ๐Ÿ“™:4:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {3} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -998,9 +998,9 @@ fn main_with_advanced_remote_tracking_branch() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ] +โŒ‚:main[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] {1} + โ””โ”€โ”€ :main[๐ŸŒณ] โ””โ”€โ”€ ยท3183e43 "#]] @@ -1051,11 +1051,11 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ“™:1:feature {2ec} - โ”œโ”€โ”€ ๐Ÿ“™:1:feature +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ“™:feature {2ec} + โ”œโ”€โ”€ ๐Ÿ“™:feature โ”‚ โ””โ”€โ”€ ยท6b40b15 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:2:main + โ””โ”€โ”€ ๐Ÿ“™:main โ””โ”€โ”€ ยท3183e43 (๐Ÿ˜๏ธ) "#]] @@ -1122,9 +1122,9 @@ fn unapply_remotely_tracked_tip_of_multi_segment_stack_can_delete_workspace_ref( snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:B <> origin/B โ†’:5: on 85efbe4 {42} - โ””โ”€โ”€ ๐Ÿ“™:3:B <> origin/B โ†’:5: +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B <> origin/B on 85efbe4 {42} + โ””โ”€โ”€ ๐Ÿ“™:B <> origin/B โ”œโ”€โ”€ โ„๏ธf084d61 (๐Ÿ˜๏ธ) โ–บA, โ–บC โ””โ”€โ”€ โ„๏ธ7076dee (๐Ÿ˜๏ธ) โ–บD, โ–บE @@ -1168,9 +1168,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&out.workspace).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] <> origin/main โ†’:1: {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ] <> origin/main โ†’:1: +โŒ‚:main[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] <> origin/main {1} + โ””โ”€โ”€ :main[๐ŸŒณ] <> origin/main "#]] ); @@ -1208,12 +1208,12 @@ fn workspace_with_out_of_ws_ref_and_anon_stack() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก:4:anon: on 3183e43 -โ”‚ โ””โ”€โ”€ :4:anon: +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก:anon: on 3183e43 +โ”‚ โ””โ”€โ”€ :anon: โ”‚ โ””โ”€โ”€ ยทd6bdeab (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:outside โ†’:3: on 3183e43 {1} - โ””โ”€โ”€ ๐Ÿ“™:5:outside โ†’:3: +โ””โ”€โ”€ โ‰ก๐Ÿ“™:outside on 3183e43 {1} + โ””โ”€โ”€ ๐Ÿ“™:outside โ”œโ”€โ”€ ยท5121eb9* โ””โ”€โ”€ ยท67c6397 (๐Ÿ˜๏ธ) @@ -1242,16 +1242,16 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&out.workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก:5:anon: on 3183e43 -โ”‚ โ””โ”€โ”€ :5:anon: +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก:anon: on 3183e43 +โ”‚ โ””โ”€โ”€ :anon: โ”‚ โ””โ”€โ”€ ยทd6bdeab (๐Ÿ˜๏ธ) -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:outside on 3183e43 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:outside +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:outside on 3183e43 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:outside โ”‚ โ”œโ”€โ”€ ยท5121eb9 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยท67c6397 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:feature on 3183e43 {2ec} - โ””โ”€โ”€ ๐Ÿ“™:4:feature +โ””โ”€โ”€ โ‰ก๐Ÿ“™:feature on 3183e43 {2ec} + โ””โ”€โ”€ ๐Ÿ“™:feature โ””โ”€โ”€ ยทd03b217 (๐Ÿ˜๏ธ) "#]] @@ -1277,7 +1277,7 @@ fn ws_ref_no_ws_commit_two_stacks_on_same_commit() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 "#]] ); @@ -1300,9 +1300,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:2:A on e5d0542 {41} - โ””โ”€โ”€ ๐Ÿ“™:2:A +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on e5d0542 {41} + โ””โ”€โ”€ ๐Ÿ“™:A "#]] ); @@ -1334,11 +1334,11 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:A on e5d0542 {41} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:2:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:B on e5d0542 {42} - โ””โ”€โ”€ ๐Ÿ“™:3:B +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on e5d0542 {41} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on e5d0542 {42} + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -1357,9 +1357,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:2:A on e5d0542 {41} - โ””โ”€โ”€ ๐Ÿ“™:2:A +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on e5d0542 {41} + โ””โ”€โ”€ ๐Ÿ“™:A "#]] ); @@ -1381,7 +1381,7 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&out.workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 "#]] ); @@ -1410,12 +1410,12 @@ fn unapply_natural_stack_with_partial_workspace_metadata() -> anyhow::Result<()> snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:B on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B โ”‚ โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก:4:A on 85efbe4 - โ””โ”€โ”€ :4:A +โ””โ”€โ”€ โ‰ก:A on 85efbe4 + โ””โ”€โ”€ :A โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] @@ -1433,9 +1433,9 @@ fn unapply_natural_stack_with_partial_workspace_metadata() -> anyhow::Result<()> snapbox::assert_data_eq!( graph_workspace(&out.workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:B on 85efbe4 {1} - โ””โ”€โ”€ ๐Ÿ“™:3:B +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -1486,14 +1486,14 @@ fn unapply_natural_stack_branch_without_workspace_metadata() -> anyhow::Result<( snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 893d602 -โ”œโ”€โ”€ โ‰ก:3:C on 893d602 -โ”‚ โ”œโ”€โ”€ :3:C +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 893d602 +โ”œโ”€โ”€ โ‰ก:C on 893d602 +โ”‚ โ”œโ”€โ”€ :C โ”‚ โ”‚ โ””โ”€โ”€ ยท356de85 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ :5:B +โ”‚ โ””โ”€โ”€ :B โ”‚ โ””โ”€โ”€ ยทf25f65c (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก:4:A on 893d602 - โ””โ”€โ”€ :4:A +โ””โ”€โ”€ โ‰ก:A on 893d602 + โ””โ”€โ”€ :A โ””โ”€โ”€ ยท26e45af (๐Ÿ˜๏ธ) "#]] @@ -1511,9 +1511,9 @@ fn unapply_natural_stack_branch_without_workspace_metadata() -> anyhow::Result<( snapbox::assert_data_eq!( graph_workspace_determinisitcally(&out.workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 893d602 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on 893d602 {1} - โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 893d602 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 893d602 {1} + โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ ยท26e45af (๐Ÿ˜๏ธ) "#]] @@ -1613,9 +1613,9 @@ fn no_ws_ref_no_ws_commit_two_stacks_on_same_commit_ad_hoc_workspace_without_tar snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ] +โŒ‚:main[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] {1} + โ””โ”€โ”€ :main[๐ŸŒณ] โ””โ”€โ”€ ยทe5d0542 โ–บA, โ–บB "#]] @@ -1640,11 +1640,11 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&out.workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:main on e5d0542 {1a5} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:2:main -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on e5d0542 {41} - โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:main on e5d0542 {1a5} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:main +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on e5d0542 {41} + โ””โ”€โ”€ ๐Ÿ“™:A "#]] ); @@ -1686,13 +1686,13 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:main on e5d0542 {1a5} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:2:main -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:B on e5d0542 {42} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:B -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:A on e5d0542 {41} - โ””โ”€โ”€ ๐Ÿ“™:4:A +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:main on e5d0542 {1a5} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:main +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on e5d0542 {42} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on e5d0542 {41} + โ””โ”€โ”€ ๐Ÿ“™:A "#]] ); @@ -1720,9 +1720,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:1:anon: {41} - โ””โ”€โ”€ :1:anon: +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:anon: {41} + โ””โ”€โ”€ :anon: โ””โ”€โ”€ ยทe5d0542 (๐Ÿ˜๏ธ) โ–บA, โ–บB, โ–บmain "#]] @@ -1752,9 +1752,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ“™:2:A {41} - โ””โ”€โ”€ ๐Ÿ“™:2:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A {41} + โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ ยทe5d0542 (๐Ÿ˜๏ธ) โ–บB, โ–บmain "#]] @@ -1787,11 +1787,11 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:B on e5d0542 {42} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:2:B -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on e5d0542 {41} - โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on e5d0542 {42} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on e5d0542 {41} + โ””โ”€โ”€ ๐Ÿ“™:A "#]] ); @@ -1825,9 +1825,9 @@ fn no_ws_ref_no_ws_commit_two_stacks_on_same_commit_ad_hoc_workspace_with_target snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e5d0542 -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] <> origin/main โ†’:1: {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ] <> origin/main โ†’:1: +โŒ‚:main[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e5d0542 +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] <> origin/main {1} + โ””โ”€โ”€ :main[๐ŸŒณ] <> origin/main "#]] ); @@ -1852,9 +1852,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e5d0542 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on e5d0542 {41} - โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e5d0542 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on e5d0542 {41} + โ””โ”€โ”€ ๐Ÿ“™:A "#]] ); @@ -1885,11 +1885,11 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e5d0542 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on e5d0542 {41} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on e5d0542 {42} - โ””โ”€โ”€ ๐Ÿ“™:4:B +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e5d0542 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on e5d0542 {41} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on e5d0542 {42} + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -1932,9 +1932,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e5d0542 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on e5d0542 {41} - โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e5d0542 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on e5d0542 {41} + โ””โ”€โ”€ ๐Ÿ“™:A "#]] ); @@ -1957,9 +1957,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&out.workspace).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e5d0542 -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] <> origin/main โ†’:1: {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ] <> origin/main โ†’:1: +โŒ‚:main[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e5d0542 +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] <> origin/main {1} + โ””โ”€โ”€ :main[๐ŸŒณ] <> origin/main "#]] ); @@ -2127,12 +2127,12 @@ fn apply_from_enclosed_adhoc_workspace_rebuilds_around_current_and_applied() -> snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:1:gitbutler/workspace <> โœ“refs/remotes/origin/main on 893d602 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:4:A on 893d602 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on 893d602 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 893d602 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยทccf539c (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:0:B[๐ŸŒณ] on 893d602 {2} - โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:0:B[๐ŸŒณ] +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:B[๐ŸŒณ] on 893d602 {2} + โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:B[๐ŸŒณ] โ””โ”€โ”€ ยท53c254d (๐Ÿ˜๏ธ) "#]] @@ -2292,9 +2292,9 @@ fn apply_from_adhoc_checkout_rebuilds_around_current_and_applied() -> anyhow::Re snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:C[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 893d602 -โ””โ”€โ”€ โ‰ก:0:C[๐ŸŒณ] on 893d602 {1} - โ””โ”€โ”€ :0:C[๐ŸŒณ] +โŒ‚:C[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 893d602 +โ””โ”€โ”€ โ‰ก:C[๐ŸŒณ] on 893d602 {1} + โ””โ”€โ”€ :C[๐ŸŒณ] โ””โ”€โ”€ ยท863775d "#]] @@ -2439,7 +2439,7 @@ fn new_workspace_exists_elsewhere_and_to_be_applied_branch_exists_there() -> any snapbox::assert_data_eq!( graph_workspace(&ws_graph.into_workspace()?).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 "#]] ); @@ -2458,9 +2458,9 @@ fn new_workspace_exists_elsewhere_and_to_be_applied_branch_exists_there() -> any snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:1:B <> โœ“! -โ””โ”€โ”€ โ‰ก:1:B {1} - โ””โ”€โ”€ :1:B +โŒ‚:B <> โœ“! +โ””โ”€โ”€ โ‰ก:B {1} + โ””โ”€โ”€ :B โ””โ”€โ”€ ยทe5d0542 โ–บA, โ–บmain "#]] @@ -2486,11 +2486,11 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:B on e5d0542 {42} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:2:B -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on e5d0542 {41} - โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on e5d0542 {42} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on e5d0542 {41} + โ””โ”€โ”€ ๐Ÿ“™:A "#]] ); @@ -2552,11 +2552,11 @@ mod unapply_checked_out { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:1:gitbutler/workspace <> โœ“! on e5d0542 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:A on e5d0542 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:2:A -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:3:B[๐ŸŒณ] on e5d0542 {2} - โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:3:B[๐ŸŒณ] +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“! on e5d0542 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on e5d0542 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:B[๐ŸŒณ] on e5d0542 {2} + โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:B[๐ŸŒณ] "#]] ); @@ -2604,12 +2604,12 @@ mod unapply_checked_out { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:C on 3183e43 {43} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:2:C +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 3183e43 {43} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยทaaa195b (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:B on 3183e43 {42} - โ””โ”€โ”€ ๐Ÿ“™:3:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {42} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทf57c528 (๐Ÿ˜๏ธ) "#]] @@ -2628,12 +2628,12 @@ mod unapply_checked_out { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:1:gitbutler/workspace <> โœ“! on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:C on 3183e43 {43} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:2:C +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace <> โœ“! on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 3183e43 {43} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยทaaa195b (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:0:B[๐ŸŒณ] on 3183e43 {42} - โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:0:B[๐ŸŒณ] +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:B[๐ŸŒณ] on 3183e43 {42} + โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:B[๐ŸŒณ] โ””โ”€โ”€ ยทf57c528 (๐Ÿ˜๏ธ) "#]] @@ -2671,9 +2671,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&out.workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ“™:1:A {1} - โ””โ”€โ”€ ๐Ÿ“™:1:A +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A {1} + โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ ยทe5d0542 (๐Ÿ˜๏ธ) โ–บmain "#]] @@ -2718,9 +2718,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&out.workspace).to_string(), snapbox::str![[r#" -โŒ‚:0:A[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ“™:0:A[๐ŸŒณ] {1} - โ””โ”€โ”€ ๐Ÿ“™:0:A[๐ŸŒณ] +โŒ‚:A[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A[๐ŸŒณ] {1} + โ””โ”€โ”€ ๐Ÿ“™:A[๐ŸŒณ] โ””โ”€โ”€ ยทe5d0542 โ–บmain "#]] @@ -2758,10 +2758,10 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:1:gitbutler/workspace <> โœ“! on e5d0542 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:2:B on e5d0542 {2} - โ”œโ”€โ”€ ๐Ÿ“™:2:B - โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:3:A[๐ŸŒณ] +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“! on e5d0542 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on e5d0542 {2} + โ”œโ”€โ”€ ๐Ÿ“™:B + โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:A[๐ŸŒณ] "#]] ); @@ -2790,7 +2790,7 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&out.workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 "#]] ); @@ -2832,9 +2832,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&out.workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:1:gitbutler/workspace <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:0:B[๐ŸŒณ] {2} - โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:0:B[๐ŸŒณ] +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:B[๐ŸŒณ] {2} + โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:B[๐ŸŒณ] โ””โ”€โ”€ ยทe5d0542 (๐Ÿ˜๏ธ) โ–บmain "#]] @@ -2879,11 +2879,11 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&out.workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ“™:1:C {43} - โ”œโ”€โ”€ ๐Ÿ“™:1:C +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C {43} + โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยทaaa195b (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :2:main + โ””โ”€โ”€ :main โ””โ”€โ”€ ยท3183e43 (๐Ÿ˜๏ธ) "#]] @@ -2931,11 +2931,11 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&out.workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:1:gitbutler/workspace <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:0:B[๐ŸŒณ] {42} - โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:0:B[๐ŸŒณ] +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:B[๐ŸŒณ] {42} + โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:B[๐ŸŒณ] โ”‚ โ””โ”€โ”€ ยทf57c528 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :2:main + โ””โ”€โ”€ :main โ””โ”€โ”€ ยท3183e43 (๐Ÿ˜๏ธ) "#]] @@ -2983,9 +2983,9 @@ fn apply_multiple_without_target_or_metadata_or_base() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ] +โŒ‚:main[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] {1} + โ””โ”€โ”€ :main[๐ŸŒณ] โ”œโ”€โ”€ ยทb1540e5 โ””โ”€โ”€ ยทe31e6ca @@ -3010,12 +3010,12 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e31e6ca -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:1:main on e31e6ca {1a5} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:1:main +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e31e6ca +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:main on e31e6ca {1a5} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:main โ”‚ โ””โ”€โ”€ ยทb1540e5 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:2:A on e31e6ca {41} - โ””โ”€โ”€ ๐Ÿ“™:2:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on e31e6ca {41} + โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ ยทbf53300 (๐Ÿ˜๏ธ) "#]] @@ -3055,15 +3055,15 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e31e6ca -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:1:main on e31e6ca {1a5} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:1:main +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e31e6ca +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:main on e31e6ca {1a5} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:main โ”‚ โ””โ”€โ”€ ยทb1540e5 (๐Ÿ˜๏ธ) -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:A on e31e6ca {41} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:2:A +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on e31e6ca {41} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยทbf53300 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:B on e31e6ca {42} - โ””โ”€โ”€ ๐Ÿ“™:3:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on e31e6ca {42} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยท0e391b2 (๐Ÿ˜๏ธ) "#]] @@ -3121,12 +3121,12 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e31e6ca -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:1:main on e31e6ca {1a5} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:1:main +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e31e6ca +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:main on e31e6ca {1a5} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:main โ”‚ โ””โ”€โ”€ ยทb1540e5 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:2:A on e31e6ca {41} - โ””โ”€โ”€ ๐Ÿ“™:2:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on e31e6ca {41} + โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ ยทbf53300 (๐Ÿ˜๏ธ) "#]] @@ -3163,9 +3163,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ“™:1:main {1a5} - โ””โ”€โ”€ ๐Ÿ“™:1:main +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ“™:main {1a5} + โ””โ”€โ”€ ๐Ÿ“™:main โ”œโ”€โ”€ ยทb1540e5 (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยทe31e6ca (๐Ÿ˜๏ธ) @@ -3207,9 +3207,9 @@ fn unapply_dirty_worktree_abort_keeps_refs_and_metadata() -> anyhow::Result<()> snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ] +โŒ‚:main[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] {1} + โ””โ”€โ”€ :main[๐ŸŒณ] โ”œโ”€โ”€ ยทb1540e5 โ””โ”€โ”€ ยทe31e6ca @@ -3229,15 +3229,15 @@ fn unapply_dirty_worktree_abort_keeps_refs_and_metadata() -> anyhow::Result<()> snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e31e6ca -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:1:main on e31e6ca {1a5} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:1:main +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e31e6ca +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:main on e31e6ca {1a5} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:main โ”‚ โ””โ”€โ”€ ยทb1540e5 (๐Ÿ˜๏ธ) -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:A on e31e6ca {41} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:2:A +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on e31e6ca {41} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยทbf53300 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:B on e31e6ca {42} - โ””โ”€โ”€ ๐Ÿ“™:3:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on e31e6ca {42} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยท0e391b2 (๐Ÿ˜๏ธ) "#]] @@ -3366,9 +3366,9 @@ fn apply_multiple_segments_of_stack_in_order_merge_if_needed() -> anyhow::Result snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] <> origin/main โ†’:1: {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ] <> origin/main โ†’:1: +โŒ‚:main[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] <> origin/main {1} + โ””โ”€โ”€ :main[๐ŸŒณ] <> origin/main "#]] ); @@ -3431,12 +3431,12 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:unrelated on 3183e43 {3c4} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:unrelated +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:unrelated on 3183e43 {3c4} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:unrelated โ”‚ โ””โ”€โ”€ ยท53ad0c2 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:A1 on 3183e43 {72} - โ””โ”€โ”€ ๐Ÿ“™:4:A1 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A1 on 3183e43 {72} + โ””โ”€โ”€ ๐Ÿ“™:A1 โ””โ”€โ”€ ยท7de99e1 (๐Ÿ˜๏ธ) "#]] @@ -3469,14 +3469,14 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:unrelated on 3183e43 {3c4} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:unrelated +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:unrelated on 3183e43 {3c4} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:unrelated โ”‚ โ””โ”€โ”€ ยท53ad0c2 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:A2 on 3183e43 {73} - โ”œโ”€โ”€ ๐Ÿ“™:4:A2 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A2 on 3183e43 {73} + โ”œโ”€โ”€ ๐Ÿ“™:A2 โ”‚ โ””โ”€โ”€ ยทf1889e7 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:A1 + โ””โ”€โ”€ ๐Ÿ“™:A1 โ””โ”€โ”€ ยท7de99e1 (๐Ÿ˜๏ธ) "#]] @@ -3552,9 +3552,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:unrelated on 3183e43 {3c4} - โ””โ”€โ”€ ๐Ÿ“™:3:unrelated +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:unrelated on 3183e43 {3c4} + โ””โ”€โ”€ ๐Ÿ“™:unrelated โ””โ”€โ”€ ยท53ad0c2 (๐Ÿ˜๏ธ) "#]] @@ -3583,9 +3583,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:unrelated on 3183e43 {3c4} - โ””โ”€โ”€ ๐Ÿ“™:3:unrelated +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:unrelated on 3183e43 {3c4} + โ””โ”€โ”€ ๐Ÿ“™:unrelated โ””โ”€โ”€ ยท53ad0c2 (๐Ÿ˜๏ธ) "#]] @@ -3615,7 +3615,7 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 "#]] ); @@ -3660,9 +3660,9 @@ fn unapply_existing_branch_outside_detached_ad_hoc_workspace_is_noop() -> anyhow snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:DETACHED <> โœ“! on 3183e43 -โ””โ”€โ”€ โ‰ก:0:anon: on 3183e43 {1} - โ””โ”€โ”€ :0:anon: +โŒ‚:DETACHED <> โœ“! on 3183e43 +โ””โ”€โ”€ โ‰ก:anon: on 3183e43 {1} + โ””โ”€โ”€ :anon: โ””โ”€โ”€ ยทaaa195b โ–บC "#]] @@ -3720,13 +3720,13 @@ fn unapply_branch_from_detached_ad_hoc_workspace_is_an_error() -> anyhow::Result snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:DETACHED <> โœ“! -โ””โ”€โ”€ โ‰ก:0:anon: {1} - โ”œโ”€โ”€ :0:anon: +โŒ‚:DETACHED <> โœ“! +โ””โ”€โ”€ โ‰ก:anon: {1} + โ”œโ”€โ”€ :anon: โ”‚ โ””โ”€โ”€ ยทf1889e7 โ–บA2 - โ”œโ”€โ”€ :1:A1 + โ”œโ”€โ”€ :A1 โ”‚ โ””โ”€โ”€ ยท7de99e1 - โ””โ”€โ”€ :2:main[๐ŸŒณ] + โ””โ”€โ”€ :main[๐ŸŒณ] โ””โ”€โ”€ ยท3183e43 "#]] @@ -3766,9 +3766,9 @@ fn detached_head_journey() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:DETACHED <> โœ“! on 3183e43 -โ””โ”€โ”€ โ‰ก:0:anon: on 3183e43 {1} - โ””โ”€โ”€ :0:anon: +โŒ‚:DETACHED <> โœ“! on 3183e43 +โ””โ”€โ”€ โ‰ก:anon: on 3183e43 {1} + โ””โ”€โ”€ :anon: โ””โ”€โ”€ ยทaaa195b โ–บC "#]] @@ -3794,9 +3794,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:2:C on 3183e43 {43} - โ””โ”€โ”€ ๐Ÿ“™:2:C +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 3183e43 {43} + โ””โ”€โ”€ ๐Ÿ“™:C โ””โ”€โ”€ ยทaaa195b (๐Ÿ˜๏ธ) "#]] @@ -3851,12 +3851,12 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:C on 3183e43 {43} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:2:C +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 3183e43 {43} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยทaaa195b (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:B on 3183e43 {42} - โ””โ”€โ”€ ๐Ÿ“™:3:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {42} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทf57c528 (๐Ÿ˜๏ธ) "#]] @@ -3889,15 +3889,15 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:A on 3183e43 {41} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:2:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {41} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:C on 3183e43 {43} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:C +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 3183e43 {43} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยทaaa195b (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 3183e43 {42} - โ””โ”€โ”€ ๐Ÿ“™:4:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {42} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทf57c528 (๐Ÿ˜๏ธ) "#]] @@ -3941,12 +3941,12 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:C on 3183e43 {43} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:2:C +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 3183e43 {43} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยทaaa195b (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:B on 3183e43 {42} - โ””โ”€โ”€ ๐Ÿ“™:3:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {42} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทf57c528 (๐Ÿ˜๏ธ) "#]] @@ -3974,9 +3974,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:2:C on 3183e43 {43} - โ””โ”€โ”€ ๐Ÿ“™:2:C +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 3183e43 {43} + โ””โ”€โ”€ ๐Ÿ“™:C โ””โ”€โ”€ ยทaaa195b (๐Ÿ˜๏ธ) "#]] @@ -4005,7 +4005,7 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 "#]] ); @@ -4062,15 +4062,15 @@ fn unapply_workspace_ref_without_target_checks_out_named_stack() -> anyhow::Resu snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:A on 3183e43 {41} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:2:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {41} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:C on 3183e43 {43} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:C +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 3183e43 {43} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยทaaa195b (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 3183e43 {42} - โ””โ”€โ”€ ๐Ÿ“™:4:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {42} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทf57c528 (๐Ÿ˜๏ธ) "#]] @@ -4116,9 +4116,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:A[๐ŸŒณ] <> โœ“! on 3183e43 -โ””โ”€โ”€ โ‰ก:0:A[๐ŸŒณ] on 3183e43 {1} - โ””โ”€โ”€ :0:A[๐ŸŒณ] +โŒ‚:A[๐ŸŒณ] <> โœ“! on 3183e43 +โ””โ”€โ”€ โ‰ก:A[๐ŸŒณ] on 3183e43 {1} + โ””โ”€โ”€ :A[๐ŸŒณ] โ””โ”€โ”€ ยท49d4b34 "#]] @@ -4190,11 +4190,11 @@ fn unapply_workspace_ref_refuses_conflicted_named_stack_checkout() -> anyhow::Re snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ“™:1:tip-conflicted {595} - โ”œโ”€โ”€ ๐Ÿ“™:1:tip-conflicted +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ“™:tip-conflicted {595} + โ”œโ”€โ”€ ๐Ÿ“™:tip-conflicted โ”‚ โ””โ”€โ”€ ยท8450331 (๐Ÿ˜๏ธ) โ–บtags/conflicted - โ””โ”€โ”€ ๐Ÿ“™:2:main + โ””โ”€โ”€ ๐Ÿ“™:main โ””โ”€โ”€ ยทa047f81 (๐Ÿ˜๏ธ) โ–บtags/normal "#]] @@ -4249,9 +4249,9 @@ fn apply_two_ambiguous_stacks_with_target_with_dependent_branch() -> anyhow::Res snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] <> origin/main โ†’:1: {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ] <> origin/main โ†’:1: +โŒ‚:main[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] <> origin/main {1} + โ””โ”€โ”€ :main[๐ŸŒณ] <> origin/main "#]] ); @@ -4275,9 +4275,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:E on 85efbe4 {1} - โ””โ”€โ”€ ๐Ÿ“™:4:E +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:E on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:E โ””โ”€โ”€ ยท7076dee (๐Ÿ˜๏ธ) โ–บD "#]] @@ -4291,12 +4291,12 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:5:E on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:5:E +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:E on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:E โ”‚ โ””โ”€โ”€ ยท7076dee (๐Ÿ˜๏ธ) โ–บD -โ””โ”€โ”€ โ‰ก๐Ÿ“™:6:C on 7076dee {43} - โ””โ”€โ”€ ๐Ÿ“™:6:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 7076dee {43} + โ””โ”€โ”€ ๐Ÿ“™:C โ””โ”€โ”€ ยทf084d61 (๐Ÿ˜๏ธ) โ–บA, โ–บB "#]] @@ -4326,12 +4326,12 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:5:E on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:5:E +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:E on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:E โ”‚ โ””โ”€โ”€ ยท7076dee (๐Ÿ˜๏ธ) โ–บD -โ””โ”€โ”€ โ‰ก๐Ÿ“™:6:B on 7076dee {2} - โ””โ”€โ”€ ๐Ÿ“™:6:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 7076dee {2} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทf084d61 (๐Ÿ˜๏ธ) โ–บA, โ–บC "#]] @@ -4347,13 +4347,13 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:5:E on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:5:E +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:E on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:E โ”‚ โ””โ”€โ”€ ยท7076dee (๐Ÿ˜๏ธ) โ–บD -โ””โ”€โ”€ โ‰ก๐Ÿ“™:6:C on 7076dee {2} - โ”œโ”€โ”€ ๐Ÿ“™:6:C - โ””โ”€โ”€ ๐Ÿ“™:7:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 7076dee {2} + โ”œโ”€โ”€ ๐Ÿ“™:C + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทf084d61 (๐Ÿ˜๏ธ) โ–บA "#]] @@ -4383,9 +4383,9 @@ fn apply_two_ambiguous_stacks_with_target() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] <> origin/main โ†’:1: {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ] <> origin/main โ†’:1: +โŒ‚:main[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] <> origin/main {1} + โ””โ”€โ”€ :main[๐ŸŒณ] <> origin/main "#]] ); @@ -4408,9 +4408,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {41} - โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {41} + โ””โ”€โ”€ ๐Ÿ“™:A โ”œโ”€โ”€ ยทf084d61 (๐Ÿ˜๏ธ) โ–บB, โ–บC โ””โ”€โ”€ ยท7076dee (๐Ÿ˜๏ธ) โ–บD, โ–บE @@ -4447,10 +4447,10 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 85efbe4 {41} - โ”œโ”€โ”€ ๐Ÿ“™:4:B - โ””โ”€โ”€ ๐Ÿ“™:5:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {41} + โ”œโ”€โ”€ ๐Ÿ“™:B + โ””โ”€โ”€ ๐Ÿ“™:A โ”œโ”€โ”€ ยทf084d61 (๐Ÿ˜๏ธ) โ–บC โ””โ”€โ”€ ยท7076dee (๐Ÿ˜๏ธ) โ–บD, โ–บE @@ -4489,11 +4489,11 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 85efbe4 {41} - โ”œโ”€โ”€ ๐Ÿ“™:4:B - โ”œโ”€โ”€ ๐Ÿ“™:5:C - โ””โ”€โ”€ ๐Ÿ“™:6:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {41} + โ”œโ”€โ”€ ๐Ÿ“™:B + โ”œโ”€โ”€ ๐Ÿ“™:C + โ””โ”€โ”€ ๐Ÿ“™:A โ”œโ”€โ”€ ยทf084d61 (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยท7076dee (๐Ÿ˜๏ธ) โ–บD, โ–บE @@ -4532,13 +4532,13 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:B on 85efbe4 {41} - โ”œโ”€โ”€ ๐Ÿ“™:5:B - โ”œโ”€โ”€ ๐Ÿ“™:6:C - โ”œโ”€โ”€ ๐Ÿ“™:7:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {41} + โ”œโ”€โ”€ ๐Ÿ“™:B + โ”œโ”€โ”€ ๐Ÿ“™:C + โ”œโ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยทf084d61 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:4:D + โ””โ”€โ”€ ๐Ÿ“™:D โ””โ”€โ”€ ยท7076dee (๐Ÿ˜๏ธ) โ–บE "#]] @@ -4576,15 +4576,15 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:5:B {41} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:5:B -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:6:C -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:7:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B {41} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยทf084d61 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:8:E on 85efbe4 {44} - โ”œโ”€โ”€ ๐Ÿ“™:8:E - โ””โ”€โ”€ ๐Ÿ“™:9:D +โ””โ”€โ”€ โ‰ก๐Ÿ“™:E on 85efbe4 {44} + โ”œโ”€โ”€ ๐Ÿ“™:E + โ””โ”€โ”€ ๐Ÿ“™:D โ””โ”€โ”€ ยท7076dee (๐Ÿ˜๏ธ) "#]] @@ -4633,13 +4633,13 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:B on 85efbe4 {41} - โ”œโ”€โ”€ ๐Ÿ“™:5:B - โ”œโ”€โ”€ ๐Ÿ“™:6:C - โ”œโ”€โ”€ ๐Ÿ“™:7:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {41} + โ”œโ”€โ”€ ๐Ÿ“™:B + โ”œโ”€โ”€ ๐Ÿ“™:C + โ”œโ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยทf084d61 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:4:D + โ””โ”€โ”€ ๐Ÿ“™:D โ””โ”€โ”€ ยท7076dee (๐Ÿ˜๏ธ) "#]] @@ -4681,13 +4681,13 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:B on 85efbe4 {41} - โ”œโ”€โ”€ ๐Ÿ“™:5:B - โ”œโ”€โ”€ ๐Ÿ“™:6:C - โ”œโ”€โ”€ ๐Ÿ“™:7:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {41} + โ”œโ”€โ”€ ๐Ÿ“™:B + โ”œโ”€โ”€ ๐Ÿ“™:C + โ”œโ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยทf084d61 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:4:E + โ””โ”€โ”€ ๐Ÿ“™:E โ””โ”€โ”€ ยท7076dee (๐Ÿ˜๏ธ) "#]] @@ -4729,12 +4729,12 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:B on 85efbe4 {41} - โ”œโ”€โ”€ ๐Ÿ“™:5:B - โ”œโ”€โ”€ ๐Ÿ“™:6:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {41} + โ”œโ”€โ”€ ๐Ÿ“™:B + โ”œโ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยทf084d61 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:4:E + โ””โ”€โ”€ ๐Ÿ“™:E โ””โ”€โ”€ ยท7076dee (๐Ÿ˜๏ธ) โ–บD "#]] @@ -4776,7 +4776,7 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 "#]] ); @@ -4818,7 +4818,7 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 "#]] ); @@ -4862,9 +4862,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] <> origin/main โ†’:1: {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ] <> origin/main โ†’:1: +โŒ‚:main[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] <> origin/main {1} + โ””โ”€โ”€ :main[๐ŸŒณ] <> origin/main "#]] ); @@ -4952,23 +4952,23 @@ fn apply_with_conflicts_shows_exact_conflict_info() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:7:main on 85efbe4 {1a5} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:7:main -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:clean-A on 85efbe4 {271} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:2:clean-A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:main on 85efbe4 {1a5} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:main +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:clean-A on 85efbe4 {271} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:clean-A โ”‚ โ””โ”€โ”€ ยทd3cce74 (๐Ÿ˜๏ธ) -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:conflict-F1 on 85efbe4 {3f6} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:conflict-F1 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:conflict-F1 on 85efbe4 {3f6} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:conflict-F1 โ”‚ โ””โ”€โ”€ ยทbf09eae (๐Ÿ˜๏ธ) -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:4:clean-B on 85efbe4 {272} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:clean-B +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:clean-B on 85efbe4 {272} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:clean-B โ”‚ โ””โ”€โ”€ ยท115e41b (๐Ÿ˜๏ธ) -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:5:conflict-F2 on 85efbe4 {3f7} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:5:conflict-F2 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:conflict-F2 on 85efbe4 {3f7} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:conflict-F2 โ”‚ โ””โ”€โ”€ ยทf2ce66d (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:6:clean-C on 85efbe4 {273} - โ””โ”€โ”€ ๐Ÿ“™:6:clean-C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:clean-C on 85efbe4 {273} + โ””โ”€โ”€ ๐Ÿ“™:clean-C โ””โ”€โ”€ ยท34c4591 (๐Ÿ˜๏ธ) "#]] @@ -5032,23 +5032,23 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:8:main on 85efbe4 {1a5} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:8:main -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:clean-A on 85efbe4 {271} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:2:clean-A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:main on 85efbe4 {1a5} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:main +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:clean-A on 85efbe4 {271} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:clean-A โ”‚ โ””โ”€โ”€ ยทd3cce74 (๐Ÿ˜๏ธ) -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:conflict-F1 on 85efbe4 {3f6} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:conflict-F1 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:conflict-F1 on 85efbe4 {3f6} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:conflict-F1 โ”‚ โ””โ”€โ”€ ยทbf09eae (๐Ÿ˜๏ธ) -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:4:clean-B on 85efbe4 {272} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:clean-B +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:clean-B on 85efbe4 {272} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:clean-B โ”‚ โ””โ”€โ”€ ยท115e41b (๐Ÿ˜๏ธ) -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:5:conflict-F2 on 85efbe4 {3f7} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:5:conflict-F2 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:conflict-F2 on 85efbe4 {3f7} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:conflict-F2 โ”‚ โ””โ”€โ”€ ยทf2ce66d (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:6:clean-C on 85efbe4 {273} - โ””โ”€โ”€ ๐Ÿ“™:6:clean-C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:clean-C on 85efbe4 {273} + โ””โ”€โ”€ ๐Ÿ“™:clean-C โ””โ”€โ”€ ยท34c4591 (๐Ÿ˜๏ธ) "#]] @@ -5100,20 +5100,20 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:6:main on 85efbe4 {1a5} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:6:main -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:clean-A on 85efbe4 {271} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:2:clean-A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:main on 85efbe4 {1a5} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:main +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:clean-A on 85efbe4 {271} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:clean-A โ”‚ โ””โ”€โ”€ ยทd3cce74 (๐Ÿ˜๏ธ) -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:clean-B on 85efbe4 {272} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:clean-B +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:clean-B on 85efbe4 {272} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:clean-B โ”‚ โ””โ”€โ”€ ยท115e41b (๐Ÿ˜๏ธ) -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:4:clean-C on 85efbe4 {273} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:clean-C +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:clean-C on 85efbe4 {273} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:clean-C โ”‚ โ””โ”€โ”€ ยท34c4591 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:conflict-hero on 85efbe4 {52d} - โ””โ”€โ”€ ๐Ÿ“™:5:conflict-hero +โ””โ”€โ”€ โ‰ก๐Ÿ“™:conflict-hero on 85efbe4 {52d} + โ””โ”€โ”€ ๐Ÿ“™:conflict-hero โ”œโ”€โ”€ ยท4bbb93c (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยท98519e9 (๐Ÿ˜๏ธ) @@ -5269,9 +5269,9 @@ fn conflicting_apply_reports_no_applied_branches_and_names_conflicting_stacks() snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e31e6ca -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on e31e6ca {41} - โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e31e6ca +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on e31e6ca {41} + โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ ยทbf53300 (๐Ÿ˜๏ธ) "#]] @@ -5363,9 +5363,9 @@ fn unapply_with_workspace_merge_conflicts_always_works_as_conflicts_do_not_repea snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“! on 85efbe4 -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ] +โŒ‚:main[๐ŸŒณ] <> โœ“! on 85efbe4 +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] {1} + โ””โ”€โ”€ :main[๐ŸŒณ] "#]] ); @@ -5397,20 +5397,20 @@ fn unapply_with_workspace_merge_conflicts_always_works_as_conflicts_do_not_repea snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:6:main on 85efbe4 {1a5} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:6:main -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:clean-A on 85efbe4 {271} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:2:clean-A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:main on 85efbe4 {1a5} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:main +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:clean-A on 85efbe4 {271} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:clean-A โ”‚ โ””โ”€โ”€ ยทd3cce74 (๐Ÿ˜๏ธ) -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:clean-B on 85efbe4 {272} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:clean-B +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:clean-B on 85efbe4 {272} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:clean-B โ”‚ โ””โ”€โ”€ ยท115e41b (๐Ÿ˜๏ธ) -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:4:clean-C on 85efbe4 {273} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:clean-C +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:clean-C on 85efbe4 {273} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:clean-C โ”‚ โ””โ”€โ”€ ยท34c4591 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:conflict-hero on 85efbe4 {52d} - โ””โ”€โ”€ ๐Ÿ“™:5:conflict-hero +โ””โ”€โ”€ โ‰ก๐Ÿ“™:conflict-hero on 85efbe4 {52d} + โ””โ”€โ”€ ๐Ÿ“™:conflict-hero โ”œโ”€โ”€ ยท4bbb93c (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยท98519e9 (๐Ÿ˜๏ธ) @@ -5434,9 +5434,9 @@ fn unapply_with_workspace_merge_conflicts_always_works_as_conflicts_do_not_repea snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 85efbe4 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:2:main on 85efbe4 {1a5} - โ””โ”€โ”€ ๐Ÿ“™:2:main +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 85efbe4 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:main on 85efbe4 {1a5} + โ””โ”€โ”€ ๐Ÿ“™:main "#]] ); @@ -5467,11 +5467,11 @@ fn auto_checkout_of_enclosing_workspace_flat() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:A on e5d0542 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:2:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:B on e5d0542 {2} - โ””โ”€โ”€ ๐Ÿ“™:3:B +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on e5d0542 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on e5d0542 {2} + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -5509,11 +5509,11 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:A on e5d0542 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:2:A -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:3:B on e5d0542 {2} - โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:3:B +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on e5d0542 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:B on e5d0542 {2} + โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:B "#]] ); @@ -5578,11 +5578,11 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:A on e5d0542 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:2:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:B on e5d0542 {2} - โ””โ”€โ”€ ๐Ÿ“™:3:B +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on e5d0542 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on e5d0542 {2} + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -5613,10 +5613,10 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:2:B on e5d0542 {2} - โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:2:B - โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:B on e5d0542 {2} + โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:B + โ””โ”€โ”€ ๐Ÿ“™:A "#]] ); @@ -5650,7 +5650,7 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 "#]] ); @@ -5678,9 +5678,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:2:A on e5d0542 {41} - โ””โ”€โ”€ ๐Ÿ“™:2:A +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on e5d0542 {41} + โ””โ”€โ”€ ๐Ÿ“™:A "#]] ); @@ -5703,11 +5703,11 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&out.workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:A on e5d0542 {41} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:2:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:B on e5d0542 {42} - โ””โ”€โ”€ ๐Ÿ“™:3:B +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on e5d0542 {41} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on e5d0542 {42} + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -5725,11 +5725,11 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:A on e5d0542 {41} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:2:A -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:3:B on e5d0542 {42} - โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:3:B +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on e5d0542 {41} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:B on e5d0542 {42} + โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:B "#]] ); @@ -5758,9 +5758,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:2:B on e5d0542 {42} - โ””โ”€โ”€ ๐Ÿ“™:2:B +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on e5d0542 {42} + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -5794,7 +5794,7 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 "#]] ); @@ -5837,12 +5837,12 @@ fn auto_checkout_of_enclosing_workspace_with_commits() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 85efbe4 {2} - โ””โ”€โ”€ ๐Ÿ“™:4:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -5876,12 +5876,12 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:1:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:4:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:0:B on 85efbe4 {2} - โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:0:B +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:B on 85efbe4 {2} + โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -5947,12 +5947,12 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 85efbe4 {2} - โ””โ”€โ”€ ๐Ÿ“™:4:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -5987,9 +5987,9 @@ fn apply_nonexisting_branch_failure() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:1:anon: - โ””โ”€โ”€ :1:anon: +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:anon: + โ””โ”€โ”€ :anon: โ””โ”€โ”€ ยทe5d0542 (๐Ÿ˜๏ธ) โ–บA, โ–บB, โ–บmain "#]] @@ -6046,9 +6046,9 @@ fn unapply_nonexisting_branch() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:1:anon: - โ””โ”€โ”€ :1:anon: +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:anon: + โ””โ”€โ”€ :anon: โ””โ”€โ”€ ยทe5d0542 (๐Ÿ˜๏ธ) โ–บA, โ–บB, โ–บmain "#]] @@ -6096,9 +6096,9 @@ fn unborn_apply_needs_base() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ] +โŒ‚:main[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] {1} + โ””โ”€โ”€ :main[๐ŸŒณ] "#]] ); @@ -6150,9 +6150,9 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ] +โŒ‚:main[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] {1} + โ””โ”€โ”€ :main[๐ŸŒณ] "#]] ); diff --git a/crates/but-workspace/tests/workspace/branch/create_reference.rs b/crates/but-workspace/tests/workspace/branch/create_reference.rs index c44f98cde1b..7359cb42494 100644 --- a/crates/but-workspace/tests/workspace/branch/create_reference.rs +++ b/crates/but-workspace/tests/workspace/branch/create_reference.rs @@ -96,7 +96,7 @@ Single commit, no main remote/target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 "#]] ); @@ -113,9 +113,9 @@ Single commit, no main remote/target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:1:main - โ””โ”€โ”€ :1:main +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:main + โ””โ”€โ”€ :main โ””โ”€โ”€ ยท3183e43 (๐Ÿ˜๏ธ) "#]] @@ -168,7 +168,7 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 "#]] ); @@ -187,9 +187,9 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on 3183e43 {41} - โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {41} + โ””โ”€โ”€ ๐Ÿ“™:A "#]] ); @@ -213,11 +213,11 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 3183e43 {41} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 3183e43 {42} - โ””โ”€โ”€ ๐Ÿ“™:4:B +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {41} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {42} + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -235,11 +235,11 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 3183e43 {41} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 3183e43 {42} - โ””โ”€โ”€ ๐Ÿ“™:4:B +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {41} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {42} + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -260,12 +260,12 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:above-A on 3183e43 {41} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:3:above-A -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:B on 3183e43 {42} - โ””โ”€โ”€ ๐Ÿ“™:5:B +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:above-A on 3183e43 {41} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {42} + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -286,13 +286,13 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:above-A on 3183e43 {41} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:3:above-A -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:B on 3183e43 {42} - โ”œโ”€โ”€ ๐Ÿ“™:5:B - โ””โ”€โ”€ ๐Ÿ“™:6:below-B +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:above-A on 3183e43 {41} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {42} + โ”œโ”€โ”€ ๐Ÿ“™:B + โ””โ”€โ”€ ๐Ÿ“™:below-B "#]] ); @@ -307,13 +307,13 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:above-A on 3183e43 {41} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:3:above-A -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:B on 3183e43 {42} - โ”œโ”€โ”€ ๐Ÿ“™:5:B - โ””โ”€โ”€ ๐Ÿ“™:6:below-B +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:above-A on 3183e43 {41} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {42} + โ”œโ”€โ”€ ๐Ÿ“™:B + โ””โ”€โ”€ ๐Ÿ“™:below-B "#]] ); @@ -351,9 +351,9 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ””โ”€โ”€ โ‰ก:3:A on bce0c5e - โ””โ”€โ”€ :3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ””โ”€โ”€ โ‰ก:A on bce0c5e + โ””โ”€โ”€ :A โ”œโ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) @@ -379,11 +379,11 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ””โ”€โ”€ โ‰ก:4:A on bce0c5e {4cf} - โ”œโ”€โ”€ :4:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ””โ”€โ”€ โ‰ก:A on bce0c5e {4cf} + โ”œโ”€โ”€ :A โ”‚ โ””โ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:3:above-bottom + โ””โ”€โ”€ ๐Ÿ“™:above-bottom โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) "#]] @@ -406,13 +406,13 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ””โ”€โ”€ โ‰ก:4:A on bce0c5e {4cf} - โ”œโ”€โ”€ :4:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ””โ”€โ”€ โ‰ก:A on bce0c5e {4cf} + โ”œโ”€โ”€ :A โ”‚ โ””โ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ๐Ÿ“™:3:above-bottom + โ”œโ”€โ”€ ๐Ÿ“™:above-bottom โ”‚ โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:bottom + โ””โ”€โ”€ ๐Ÿ“™:bottom "#]] ); @@ -438,13 +438,13 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:above-A-commit on bce0c5e {4cf} - โ”œโ”€โ”€ ๐Ÿ“™:3:above-A-commit +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ””โ”€โ”€ โ‰ก๐Ÿ“™:above-A-commit on bce0c5e {4cf} + โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit โ”‚ โ””โ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) โ–บA - โ”œโ”€โ”€ ๐Ÿ“™:4:above-bottom + โ”œโ”€โ”€ ๐Ÿ“™:above-bottom โ”‚ โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:bottom + โ””โ”€โ”€ ๐Ÿ“™:bottom "#]] ); @@ -468,14 +468,14 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:above-A-commit on bce0c5e {4cf} - โ”œโ”€โ”€ ๐Ÿ“™:5:above-A-commit - โ”œโ”€โ”€ ๐Ÿ“™:6:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ””โ”€โ”€ โ‰ก๐Ÿ“™:above-A-commit on bce0c5e {4cf} + โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit + โ”œโ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ๐Ÿ“™:4:above-bottom + โ”œโ”€โ”€ ๐Ÿ“™:above-bottom โ”‚ โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:7:bottom + โ””โ”€โ”€ ๐Ÿ“™:bottom "#]] ); @@ -499,15 +499,15 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:above-A-commit on bce0c5e {4cf} - โ”œโ”€โ”€ ๐Ÿ“™:5:above-A-commit - โ”œโ”€โ”€ ๐Ÿ“™:6:above-A - โ”œโ”€โ”€ ๐Ÿ“™:7:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ””โ”€โ”€ โ‰ก๐Ÿ“™:above-A-commit on bce0c5e {4cf} + โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit + โ”œโ”€โ”€ ๐Ÿ“™:above-A + โ”œโ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ๐Ÿ“™:4:above-bottom + โ”œโ”€โ”€ ๐Ÿ“™:above-bottom โ”‚ โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:8:bottom + โ””โ”€โ”€ ๐Ÿ“™:bottom "#]] ); @@ -529,16 +529,16 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:above-A-commit on bce0c5e {4cf} - โ”œโ”€โ”€ ๐Ÿ“™:5:above-A-commit - โ”œโ”€โ”€ ๐Ÿ“™:6:above-A - โ”œโ”€โ”€ ๐Ÿ“™:7:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ””โ”€โ”€ โ‰ก๐Ÿ“™:above-A-commit on bce0c5e {4cf} + โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit + โ”œโ”€โ”€ ๐Ÿ“™:above-A + โ”œโ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ๐Ÿ“™:8:below-A-commit - โ”œโ”€โ”€ ๐Ÿ“™:9:above-bottom + โ”œโ”€โ”€ ๐Ÿ“™:below-A-commit + โ”œโ”€โ”€ ๐Ÿ“™:above-bottom โ”‚ โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:10:bottom + โ””โ”€โ”€ ๐Ÿ“™:bottom "#]] ); @@ -559,17 +559,17 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:above-A-commit on bce0c5e {4cf} - โ”œโ”€โ”€ ๐Ÿ“™:5:above-A-commit - โ”œโ”€โ”€ ๐Ÿ“™:6:above-A - โ”œโ”€โ”€ ๐Ÿ“™:7:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ””โ”€โ”€ โ‰ก๐Ÿ“™:above-A-commit on bce0c5e {4cf} + โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit + โ”œโ”€โ”€ ๐Ÿ“™:above-A + โ”œโ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ๐Ÿ“™:8:below-A - โ”œโ”€โ”€ ๐Ÿ“™:9:below-A-commit - โ”œโ”€โ”€ ๐Ÿ“™:10:above-bottom + โ”œโ”€โ”€ ๐Ÿ“™:below-A + โ”œโ”€โ”€ ๐Ÿ“™:below-A-commit + โ”œโ”€โ”€ ๐Ÿ“™:above-bottom โ”‚ โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:11:bottom + โ””โ”€โ”€ ๐Ÿ“™:bottom "#]] ); @@ -588,19 +588,19 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:6:above-A-commit on bce0c5e {4cf} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:6:above-A-commit -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:7:above-A -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:8:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:above-A-commit on bce0c5e {4cf} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A โ”‚ โ”‚ โ””โ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:9:below-A -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:10:below-A-commit -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:11:above-bottom +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A-commit +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-bottom โ”‚ โ”‚ โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:12:bottom -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:B on bce0c5e {42} - โ””โ”€โ”€ ๐Ÿ“™:5:B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:bottom +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on bce0c5e {42} + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -622,20 +622,20 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:7:above-A-commit on bce0c5e {4cf} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:7:above-A-commit -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:8:above-A -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:9:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:above-A-commit on bce0c5e {4cf} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A โ”‚ โ”‚ โ””โ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:10:below-A -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:11:below-A-commit -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:12:above-bottom +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A-commit +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-bottom โ”‚ โ”‚ โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:13:bottom -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:above-B on bce0c5e {42} - โ”œโ”€โ”€ ๐Ÿ“™:5:above-B - โ””โ”€โ”€ ๐Ÿ“™:6:B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:bottom +โ””โ”€โ”€ โ‰ก๐Ÿ“™:above-B on bce0c5e {42} + โ”œโ”€โ”€ ๐Ÿ“™:above-B + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -659,21 +659,21 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:8:above-A-commit on bce0c5e {4cf} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:8:above-A-commit -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:9:above-A -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:10:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:above-A-commit on bce0c5e {4cf} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A โ”‚ โ”‚ โ””โ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:11:below-A -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:12:below-A-commit -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:13:above-bottom +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A-commit +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-bottom โ”‚ โ”‚ โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:14:bottom -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:above-B on bce0c5e {42} - โ”œโ”€โ”€ ๐Ÿ“™:5:above-B - โ”œโ”€โ”€ ๐Ÿ“™:6:B - โ””โ”€โ”€ ๐Ÿ“™:7:below-B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:bottom +โ””โ”€โ”€ โ‰ก๐Ÿ“™:above-B on bce0c5e {42} + โ”œโ”€โ”€ ๐Ÿ“™:above-B + โ”œโ”€โ”€ ๐Ÿ“™:B + โ””โ”€โ”€ ๐Ÿ“™:below-B "#]] ); @@ -688,21 +688,21 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:8:above-A-commit on bce0c5e {4cf} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:8:above-A-commit -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:9:above-A -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:10:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:above-A-commit on bce0c5e {4cf} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A โ”‚ โ”‚ โ””โ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:11:below-A -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:12:below-A-commit -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:13:above-bottom +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A-commit +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-bottom โ”‚ โ”‚ โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:14:bottom -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:above-B on bce0c5e {42} - โ”œโ”€โ”€ ๐Ÿ“™:5:above-B - โ”œโ”€โ”€ ๐Ÿ“™:6:B - โ””โ”€โ”€ ๐Ÿ“™:7:below-B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:bottom +โ””โ”€โ”€ โ‰ก๐Ÿ“™:above-B on bce0c5e {42} + โ”œโ”€โ”€ ๐Ÿ“™:above-B + โ”œโ”€โ”€ ๐Ÿ“™:B + โ””โ”€โ”€ ๐Ÿ“™:below-B "#]] ); @@ -744,9 +744,9 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on 3183e43 {0} - โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {0} + โ””โ”€โ”€ ๐Ÿ“™:A โ”œโ”€โ”€ ยทc2878fb (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) @@ -770,11 +770,11 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:A on 3183e43 {0} - โ”œโ”€โ”€ ๐Ÿ“™:4:A +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {0} + โ”œโ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยทc2878fb (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:3:above-bottom + โ””โ”€โ”€ ๐Ÿ“™:above-bottom โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) "#]] @@ -799,13 +799,13 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:A on 3183e43 {0} - โ”œโ”€โ”€ ๐Ÿ“™:4:A +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {0} + โ”œโ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยทc2878fb (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ๐Ÿ“™:3:above-bottom + โ”œโ”€โ”€ ๐Ÿ“™:above-bottom โ”‚ โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:bottom + โ””โ”€โ”€ ๐Ÿ“™:bottom "#]] ); @@ -829,14 +829,14 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:A on 3183e43 {0} - โ”œโ”€โ”€ ๐Ÿ“™:5:A - โ”œโ”€โ”€ ๐Ÿ“™:6:above-A-commit +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {0} + โ”œโ”€โ”€ ๐Ÿ“™:A + โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit โ”‚ โ””โ”€โ”€ ยทc2878fb (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ๐Ÿ“™:3:above-bottom + โ”œโ”€โ”€ ๐Ÿ“™:above-bottom โ”‚ โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:7:bottom + โ””โ”€โ”€ ๐Ÿ“™:bottom "#]] ); @@ -860,15 +860,15 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:above-A on 3183e43 {0} - โ”œโ”€โ”€ ๐Ÿ“™:5:above-A - โ”œโ”€โ”€ ๐Ÿ“™:6:A - โ”œโ”€โ”€ ๐Ÿ“™:7:above-A-commit +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:above-A on 3183e43 {0} + โ”œโ”€โ”€ ๐Ÿ“™:above-A + โ”œโ”€โ”€ ๐Ÿ“™:A + โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit โ”‚ โ””โ”€โ”€ ยทc2878fb (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ๐Ÿ“™:3:above-bottom + โ”œโ”€โ”€ ๐Ÿ“™:above-bottom โ”‚ โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:8:bottom + โ””โ”€โ”€ ๐Ÿ“™:bottom "#]] ); @@ -892,15 +892,15 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:above-A on 3183e43 {0} - โ”œโ”€โ”€ ๐Ÿ“™:5:above-A - โ”œโ”€โ”€ ๐Ÿ“™:6:A - โ”œโ”€โ”€ ๐Ÿ“™:7:above-A-commit +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:above-A on 3183e43 {0} + โ”œโ”€โ”€ ๐Ÿ“™:above-A + โ”œโ”€โ”€ ๐Ÿ“™:A + โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit โ”‚ โ””โ”€โ”€ ยทc2878fb (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ๐Ÿ“™:3:above-bottom + โ”œโ”€โ”€ ๐Ÿ“™:above-bottom โ”‚ โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:8:bottom + โ””โ”€โ”€ ๐Ÿ“™:bottom "#]] ); @@ -922,16 +922,16 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:above-A on 3183e43 {0} - โ”œโ”€โ”€ ๐Ÿ“™:5:above-A - โ”œโ”€โ”€ ๐Ÿ“™:6:A - โ”œโ”€โ”€ ๐Ÿ“™:7:above-A-commit +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:above-A on 3183e43 {0} + โ”œโ”€โ”€ ๐Ÿ“™:above-A + โ”œโ”€โ”€ ๐Ÿ“™:A + โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit โ”‚ โ””โ”€โ”€ ยทc2878fb (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ๐Ÿ“™:8:below-A-commit - โ”œโ”€โ”€ ๐Ÿ“™:9:above-bottom + โ”œโ”€โ”€ ๐Ÿ“™:below-A-commit + โ”œโ”€โ”€ ๐Ÿ“™:above-bottom โ”‚ โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:10:bottom + โ””โ”€โ”€ ๐Ÿ“™:bottom "#]] ); @@ -952,17 +952,17 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:above-A on 3183e43 {0} - โ”œโ”€โ”€ ๐Ÿ“™:5:above-A - โ”œโ”€โ”€ ๐Ÿ“™:6:A - โ”œโ”€โ”€ ๐Ÿ“™:7:above-A-commit +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:above-A on 3183e43 {0} + โ”œโ”€โ”€ ๐Ÿ“™:above-A + โ”œโ”€โ”€ ๐Ÿ“™:A + โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit โ”‚ โ””โ”€โ”€ ยทc2878fb (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ๐Ÿ“™:8:below-A - โ”œโ”€โ”€ ๐Ÿ“™:9:below-A-commit - โ”œโ”€โ”€ ๐Ÿ“™:10:above-bottom + โ”œโ”€โ”€ ๐Ÿ“™:below-A + โ”œโ”€โ”€ ๐Ÿ“™:below-A-commit + โ”œโ”€โ”€ ๐Ÿ“™:above-bottom โ”‚ โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:11:bottom + โ””โ”€โ”€ ๐Ÿ“™:bottom "#]] ); @@ -981,19 +981,19 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:6:above-A on 3183e43 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:6:above-A -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:7:A -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:8:above-A-commit +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:above-A on 3183e43 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit โ”‚ โ”‚ โ””โ”€โ”€ ยทc2878fb (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:9:below-A -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:10:below-A-commit -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:11:above-bottom +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A-commit +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-bottom โ”‚ โ”‚ โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:12:bottom -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:B on 3183e43 {42} - โ””โ”€โ”€ ๐Ÿ“™:5:B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:bottom +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {42} + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -1015,20 +1015,20 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:7:above-A on 3183e43 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:7:above-A -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:8:A -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:9:above-A-commit +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:above-A on 3183e43 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit โ”‚ โ”‚ โ””โ”€โ”€ ยทc2878fb (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:10:below-A -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:11:below-A-commit -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:12:above-bottom +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A-commit +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-bottom โ”‚ โ”‚ โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:13:bottom -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:above-B on 3183e43 {42} - โ”œโ”€โ”€ ๐Ÿ“™:5:above-B - โ””โ”€โ”€ ๐Ÿ“™:6:B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:bottom +โ””โ”€โ”€ โ‰ก๐Ÿ“™:above-B on 3183e43 {42} + โ”œโ”€โ”€ ๐Ÿ“™:above-B + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -1052,21 +1052,21 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:8:above-A on 3183e43 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:8:above-A -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:9:A -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:10:above-A-commit +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:above-A on 3183e43 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit โ”‚ โ”‚ โ””โ”€โ”€ ยทc2878fb (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:11:below-A -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:12:below-A-commit -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:13:above-bottom +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A-commit +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-bottom โ”‚ โ”‚ โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:14:bottom -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:above-B on 3183e43 {42} - โ”œโ”€โ”€ ๐Ÿ“™:5:above-B - โ”œโ”€โ”€ ๐Ÿ“™:6:B - โ””โ”€โ”€ ๐Ÿ“™:7:below-B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:bottom +โ””โ”€โ”€ โ‰ก๐Ÿ“™:above-B on 3183e43 {42} + โ”œโ”€โ”€ ๐Ÿ“™:above-B + โ”œโ”€โ”€ ๐Ÿ“™:B + โ””โ”€โ”€ ๐Ÿ“™:below-B "#]] ); @@ -1081,21 +1081,21 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:8:above-A on 3183e43 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:8:above-A -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:9:A -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:10:above-A-commit +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:above-A on 3183e43 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit โ”‚ โ”‚ โ””โ”€โ”€ ยทc2878fb (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:11:below-A -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:12:below-A-commit -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:13:above-bottom +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A-commit +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-bottom โ”‚ โ”‚ โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:14:bottom -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:above-B on 3183e43 {42} - โ”œโ”€โ”€ ๐Ÿ“™:5:above-B - โ”œโ”€โ”€ ๐Ÿ“™:6:B - โ””โ”€โ”€ ๐Ÿ“™:7:below-B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:bottom +โ””โ”€โ”€ โ‰ก๐Ÿ“™:above-B on 3183e43 {42} + โ”œโ”€โ”€ ๐Ÿ“™:above-B + โ”œโ”€โ”€ ๐Ÿ“™:B + โ””โ”€โ”€ ๐Ÿ“™:below-B "#]] ); @@ -1135,9 +1135,9 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on 3183e43 {0} - โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {0} + โ””โ”€โ”€ ๐Ÿ“™:A โ”œโ”€โ”€ ยทc2878fb (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) @@ -1162,12 +1162,12 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on 3183e43 {0} - โ”œโ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {0} + โ”œโ”€โ”€ ๐Ÿ“™:A โ”‚ โ”œโ”€โ”€ ยทc2878fb (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:4:bottom + โ””โ”€โ”€ ๐Ÿ“™:bottom "#]] ); @@ -1201,12 +1201,12 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 3183e43 {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {0} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 3183e43 {1} - โ””โ”€โ”€ ๐Ÿ“™:4:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {1} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทf57c528 (๐Ÿ˜๏ธ) "#]] @@ -1229,13 +1229,13 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 3183e43 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A โ”‚ โ”‚ โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:5:a-bottom -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 3183e43 {1} - โ””โ”€โ”€ ๐Ÿ“™:4:B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:a-bottom +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {1} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทf57c528 (๐Ÿ˜๏ธ) "#]] @@ -1259,15 +1259,15 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 3183e43 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A โ”‚ โ”‚ โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:6:a-bottom -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 3183e43 {1} - โ”œโ”€โ”€ ๐Ÿ“™:4:B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:a-bottom +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {1} + โ”œโ”€โ”€ ๐Ÿ“™:B โ”‚ โ””โ”€โ”€ ยทf57c528 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:b-bottom + โ””โ”€โ”€ ๐Ÿ“™:b-bottom "#]] ); @@ -1297,9 +1297,9 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on bce0c5e {0} - โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on bce0c5e {0} + โ””โ”€โ”€ ๐Ÿ“™:A โ”œโ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) @@ -1324,11 +1324,11 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on bce0c5e {0} - โ”œโ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on bce0c5e {0} + โ”œโ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:4:foo + โ””โ”€โ”€ ๐Ÿ“™:foo โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) "#]] @@ -1353,12 +1353,12 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:A on bce0c5e {0} - โ”œโ”€โ”€ ๐Ÿ“™:5:A - โ”œโ”€โ”€ ๐Ÿ“™:6:new +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on bce0c5e {0} + โ”œโ”€โ”€ ๐Ÿ“™:A + โ”œโ”€โ”€ ๐Ÿ“™:new โ”‚ โ””โ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:4:foo + โ””โ”€โ”€ ๐Ÿ“™:foo โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) "#]] @@ -1381,13 +1381,13 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:above-A on bce0c5e {0} - โ”œโ”€โ”€ ๐Ÿ“™:5:above-A - โ”œโ”€โ”€ ๐Ÿ“™:6:A - โ”œโ”€โ”€ ๐Ÿ“™:7:new +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ””โ”€โ”€ โ‰ก๐Ÿ“™:above-A on bce0c5e {0} + โ”œโ”€โ”€ ๐Ÿ“™:above-A + โ”œโ”€โ”€ ๐Ÿ“™:A + โ”œโ”€โ”€ ๐Ÿ“™:new โ”‚ โ””โ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:4:foo + โ””โ”€โ”€ ๐Ÿ“™:foo โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) "#]] @@ -1410,14 +1410,14 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:above-A on bce0c5e {0} - โ”œโ”€โ”€ ๐Ÿ“™:5:above-A - โ”œโ”€โ”€ ๐Ÿ“™:6:A - โ”œโ”€โ”€ ๐Ÿ“™:7:below-empty-A - โ”œโ”€โ”€ ๐Ÿ“™:8:new +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ””โ”€โ”€ โ‰ก๐Ÿ“™:above-A on bce0c5e {0} + โ”œโ”€โ”€ ๐Ÿ“™:above-A + โ”œโ”€โ”€ ๐Ÿ“™:A + โ”œโ”€โ”€ ๐Ÿ“™:below-empty-A + โ”œโ”€โ”€ ๐Ÿ“™:new โ”‚ โ””โ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:4:foo + โ””โ”€โ”€ ๐Ÿ“™:foo โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) "#]] @@ -1439,14 +1439,14 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:above-A on bce0c5e {0} - โ”œโ”€โ”€ ๐Ÿ“™:5:above-A - โ”œโ”€โ”€ ๐Ÿ“™:6:A - โ”œโ”€โ”€ ๐Ÿ“™:7:below-empty-A - โ”œโ”€โ”€ ๐Ÿ“™:8:new +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ””โ”€โ”€ โ‰ก๐Ÿ“™:above-A on bce0c5e {0} + โ”œโ”€โ”€ ๐Ÿ“™:above-A + โ”œโ”€โ”€ ๐Ÿ“™:A + โ”œโ”€โ”€ ๐Ÿ“™:below-empty-A + โ”œโ”€โ”€ ๐Ÿ“™:new โ”‚ โ””โ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:4:foo + โ””โ”€โ”€ ๐Ÿ“™:foo โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) "#]] @@ -1462,14 +1462,14 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:above-A on bce0c5e {0} - โ”œโ”€โ”€ ๐Ÿ“™:5:above-A - โ”œโ”€โ”€ ๐Ÿ“™:6:A - โ”œโ”€โ”€ ๐Ÿ“™:7:below-empty-A - โ”œโ”€โ”€ ๐Ÿ“™:8:new +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ””โ”€โ”€ โ‰ก๐Ÿ“™:above-A on bce0c5e {0} + โ”œโ”€โ”€ ๐Ÿ“™:above-A + โ”œโ”€โ”€ ๐Ÿ“™:A + โ”œโ”€โ”€ ๐Ÿ“™:below-empty-A + โ”œโ”€โ”€ ๐Ÿ“™:new โ”‚ โ””โ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:4:foo + โ””โ”€โ”€ ๐Ÿ“™:foo โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) "#]] @@ -1517,9 +1517,9 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on 3183e43 {41} - โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {41} + โ””โ”€โ”€ ๐Ÿ“™:A "#]] ); @@ -1541,10 +1541,10 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on 3183e43 {41} - โ”œโ”€โ”€ ๐Ÿ“™:3:A - โ””โ”€โ”€ ๐Ÿ“™:4:below-A +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {41} + โ”œโ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ๐Ÿ“™:below-A "#]] ); @@ -1565,11 +1565,11 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:above-A on 3183e43 {41} - โ”œโ”€โ”€ ๐Ÿ“™:3:above-A - โ”œโ”€โ”€ ๐Ÿ“™:4:A - โ””โ”€โ”€ ๐Ÿ“™:5:below-A +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:above-A on 3183e43 {41} + โ”œโ”€โ”€ ๐Ÿ“™:above-A + โ”œโ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ๐Ÿ“™:below-A "#]] ); @@ -1602,9 +1602,9 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ“™:1:main {0} - โ””โ”€โ”€ ๐Ÿ“™:1:main +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ“™:main {0} + โ””โ”€โ”€ ๐Ÿ“™:main โ””โ”€โ”€ ยท3183e43 (๐Ÿ˜๏ธ) "#]] @@ -1649,10 +1649,10 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ“™:2:main {0} - โ”œโ”€โ”€ ๐Ÿ“™:2:main - โ””โ”€โ”€ ๐Ÿ“™:3:new +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ“™:main {0} + โ”œโ”€โ”€ ๐Ÿ“™:main + โ””โ”€โ”€ ๐Ÿ“™:new โ””โ”€โ”€ ยท3183e43 (๐Ÿ˜๏ธ) "#]] @@ -1672,12 +1672,12 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 3183e43 {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {0} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 3183e43 {1} - โ””โ”€โ”€ ๐Ÿ“™:4:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {1} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทf57c528 (๐Ÿ˜๏ธ) "#]] @@ -1701,13 +1701,13 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 3183e43 {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {0} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:B on 3183e43 {1} - โ”œโ”€โ”€ ๐Ÿ“™:5:B - โ””โ”€โ”€ ๐Ÿ“™:6:new +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {1} + โ”œโ”€โ”€ ๐Ÿ“™:B + โ””โ”€โ”€ ๐Ÿ“™:new โ””โ”€โ”€ ยทf57c528 (๐Ÿ˜๏ธ) "#]] @@ -1812,7 +1812,7 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on bce0c5e +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on bce0c5e "#]] ); @@ -1883,9 +1883,9 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on 3183e43 {0} - โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {0} + โ””โ”€โ”€ ๐Ÿ“™:A โ”œโ”€โ”€ ยทc2878fb (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) @@ -2039,9 +2039,9 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on 3183e43 {0} - โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {0} + โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) "#]] @@ -2074,12 +2074,12 @@ Single commit, target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&updated_ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 3183e43 {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {0} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:new-branch on 3183e43 {3e5} - โ””โ”€โ”€ ๐Ÿ“™:5:new-branch +โ””โ”€โ”€ โ‰ก๐Ÿ“™:new-branch on 3183e43 {3e5} + โ””โ”€โ”€ ๐Ÿ“™:new-branch "#]] ); @@ -2109,9 +2109,9 @@ fn errors() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ] +โŒ‚:main[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] {1} + โ””โ”€โ”€ :main[๐ŸŒณ] "#]] ); @@ -2152,9 +2152,9 @@ fn errors() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“! on c166d42 -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ] +โŒ‚:main[๐ŸŒณ] <> โœ“! on c166d42 +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] {1} + โ””โ”€โ”€ :main[๐ŸŒณ] "#]] ); @@ -2265,12 +2265,12 @@ fn errors() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:A <> โœ“! -โ””โ”€โ”€ โ‰ก:0:A {1} - โ”œโ”€โ”€ :0:A +โŒ‚:A <> โœ“! +โ””โ”€โ”€ โ‰ก:A {1} + โ”œโ”€โ”€ :A โ”‚ โ”œโ”€โ”€ ยท89cc2d3 โ”‚ โ””โ”€โ”€ ยทd79bba9 - โ””โ”€โ”€ :1:main[๐ŸŒณ] + โ””โ”€โ”€ :main[๐ŸŒณ] โ””โ”€โ”€ ยทc166d42 "#]] @@ -2322,9 +2322,9 @@ fn errors() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:A <> โœ“! on 89cc2d3 -โ””โ”€โ”€ โ‰ก:0:A {1} - โ””โ”€โ”€ :0:A +โŒ‚:A <> โœ“! on 89cc2d3 +โ””โ”€โ”€ โ‰ก:A {1} + โ””โ”€โ”€ :A "#]] ); @@ -2389,9 +2389,9 @@ fn journey_with_commits() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ] +โŒ‚:main[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] {1} + โ””โ”€โ”€ :main[๐ŸŒณ] โ”œโ”€โ”€ ยท281da94 โ”œโ”€โ”€ ยท12995d7 โ””โ”€โ”€ ยท3d57fc1 @@ -2416,11 +2416,11 @@ fn journey_with_commits() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] {1} - โ”œโ”€โ”€ :0:main[๐ŸŒณ] +โŒ‚:main[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] {1} + โ”œโ”€โ”€ :main[๐ŸŒณ] โ”‚ โ””โ”€โ”€ ยท281da94 - โ””โ”€โ”€ ๐Ÿ“™:1:below-main + โ””โ”€โ”€ ๐Ÿ“™:below-main โ”œโ”€โ”€ ยท12995d7 โ””โ”€โ”€ ยท3d57fc1 @@ -2452,11 +2452,11 @@ fn journey_with_commits() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] {1} - โ”œโ”€โ”€ :0:main[๐ŸŒณ] +โŒ‚:main[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] {1} + โ”œโ”€โ”€ :main[๐ŸŒณ] โ”‚ โ””โ”€โ”€ ยท281da94 - โ””โ”€โ”€ ๐Ÿ“™:1:below-main + โ””โ”€โ”€ ๐Ÿ“™:below-main โ”œโ”€โ”€ ยท12995d7 โ””โ”€โ”€ ยท3d57fc1 @@ -2476,13 +2476,13 @@ fn journey_with_commits() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] {1} - โ”œโ”€โ”€ :0:main[๐ŸŒณ] +โŒ‚:main[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] {1} + โ”œโ”€โ”€ :main[๐ŸŒณ] โ”‚ โ””โ”€โ”€ ยท281da94 - โ”œโ”€โ”€ ๐Ÿ“™:1:below-main + โ”œโ”€โ”€ ๐Ÿ“™:below-main โ”‚ โ””โ”€โ”€ ยท12995d7 - โ””โ”€โ”€ ๐Ÿ“™:2:two-below-main + โ””โ”€โ”€ ๐Ÿ“™:two-below-main โ””โ”€โ”€ ยท3d57fc1 "#]] @@ -2525,13 +2525,13 @@ fn journey_with_commits() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] {1} - โ”œโ”€โ”€ :0:main[๐ŸŒณ] +โŒ‚:main[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] {1} + โ”œโ”€โ”€ :main[๐ŸŒณ] โ”‚ โ””โ”€โ”€ ยท281da94 - โ”œโ”€โ”€ ๐Ÿ“™:1:below-main + โ”œโ”€โ”€ ๐Ÿ“™:below-main โ”‚ โ””โ”€โ”€ ยท12995d7 - โ””โ”€โ”€ ๐Ÿ“™:2:two-below-main + โ””โ”€โ”€ ๐Ÿ“™:two-below-main โ””โ”€โ”€ ยท3d57fc1 "#]] @@ -2559,13 +2559,13 @@ fn journey_with_commits() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ“™:0:main[๐ŸŒณ] {1} - โ”œโ”€โ”€ ๐Ÿ“™:0:main[๐ŸŒณ] +โŒ‚:main[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ“™:main[๐ŸŒณ] {1} + โ”œโ”€โ”€ ๐Ÿ“™:main[๐ŸŒณ] โ”‚ โ””โ”€โ”€ ยท281da94 - โ”œโ”€โ”€ ๐Ÿ“™:1:below-main + โ”œโ”€โ”€ ๐Ÿ“™:below-main โ”‚ โ””โ”€โ”€ ยท12995d7 - โ””โ”€โ”€ ๐Ÿ“™:2:two-below-main + โ””โ”€โ”€ ๐Ÿ“™:two-below-main โ””โ”€โ”€ ยท3d57fc1 "#]] @@ -2613,11 +2613,11 @@ fn existing_git_ref_inside_workspace_is_adopted() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ””โ”€โ”€ โ‰ก:4:A on bce0c5e {632} - โ”œโ”€โ”€ :4:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ””โ”€โ”€ โ‰ก:A on bce0c5e {632} + โ”œโ”€โ”€ :A โ”‚ โ””โ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:3:created-with-git + โ””โ”€โ”€ ๐Ÿ“™:created-with-git โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) "#]] @@ -2652,9 +2652,9 @@ fn journey_anon_workspace() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:DETACHED <> โœ“! -โ””โ”€โ”€ โ‰ก:0:anon: {1} - โ””โ”€โ”€ :0:anon: +โŒ‚:DETACHED <> โœ“! +โ””โ”€โ”€ โ‰ก:anon: {1} + โ””โ”€โ”€ :anon: โ”œโ”€โ”€ ยท12995d7 โ””โ”€โ”€ ยท3d57fc1 @@ -2678,11 +2678,11 @@ fn journey_anon_workspace() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:DETACHED <> โœ“! -โ””โ”€โ”€ โ‰ก:0:anon: {1} - โ”œโ”€โ”€ :0:anon: +โŒ‚:DETACHED <> โœ“! +โ””โ”€โ”€ โ‰ก:anon: {1} + โ”œโ”€โ”€ :anon: โ”‚ โ””โ”€โ”€ ยท12995d7 - โ””โ”€โ”€ ๐Ÿ“™:1:first + โ””โ”€โ”€ ๐Ÿ“™:first โ””โ”€โ”€ ยท3d57fc1 "#]] @@ -2723,11 +2723,11 @@ fn journey_anon_workspace() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:second <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ“™:0:second {1} - โ”œโ”€โ”€ ๐Ÿ“™:0:second +โŒ‚:second <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ“™:second {1} + โ”œโ”€โ”€ ๐Ÿ“™:second โ”‚ โ””โ”€โ”€ ยท12995d7 - โ””โ”€โ”€ ๐Ÿ“™:1:first + โ””โ”€โ”€ ๐Ÿ“™:first โ””โ”€โ”€ ยท3d57fc1 "#]] @@ -2767,9 +2767,9 @@ fn journey_anon_workspace() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:second <> โœ“! on 3d57fc1 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:0:second on 3d57fc1 {1} - โ””โ”€โ”€ ๐Ÿ“™:0:second +โŒ‚:second <> โœ“! on 3d57fc1 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:second on 3d57fc1 {1} + โ””โ”€โ”€ ๐Ÿ“™:second โ””โ”€โ”€ ยท12995d7 "#]] @@ -2847,9 +2847,9 @@ mod ad_hoc_at_reference { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:main[๐ŸŒณ] <> โœ“! on 281da94 -โ””โ”€โ”€ โ‰ก:0:main[๐ŸŒณ] {1} - โ””โ”€โ”€ :0:main[๐ŸŒณ] +โŒ‚:main[๐ŸŒณ] <> โœ“! on 281da94 +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] {1} + โ””โ”€โ”€ :main[๐ŸŒณ] "#]] ); @@ -2932,10 +2932,10 @@ mod ad_hoc_at_reference { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:1:main[๐ŸŒณ] <> โœ“! on 281da94 -โ””โ”€โ”€ โ‰ก:1:main[๐ŸŒณ] {1} - โ”œโ”€โ”€ :1:main[๐ŸŒณ] - โ””โ”€โ”€ ๐Ÿ“™:0:empty-bottom +โŒ‚:main[๐ŸŒณ] <> โœ“! on 281da94 +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] {1} + โ”œโ”€โ”€ :main[๐ŸŒณ] + โ””โ”€โ”€ ๐Ÿ“™:empty-bottom โ”œโ”€โ”€ ยท281da94 โ”œโ”€โ”€ ยท12995d7 โ””โ”€โ”€ ยท3d57fc1 @@ -2985,12 +2985,12 @@ mod ad_hoc_at_reference { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:1:main[๐ŸŒณ] <> โœ“! on 281da94 -โ””โ”€โ”€ โ‰ก:1:main[๐ŸŒณ] {1} - โ”œโ”€โ”€ :1:main[๐ŸŒณ] - โ”œโ”€โ”€ ๐Ÿ“™:2:empty-middle - โ”œโ”€โ”€ ๐Ÿ“™:3:inserted-below-middle - โ””โ”€โ”€ ๐Ÿ“™:0:empty-bottom +โŒ‚:main[๐ŸŒณ] <> โœ“! on 281da94 +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] {1} + โ”œโ”€โ”€ :main[๐ŸŒณ] + โ”œโ”€โ”€ ๐Ÿ“™:empty-middle + โ”œโ”€โ”€ ๐Ÿ“™:inserted-below-middle + โ””โ”€โ”€ ๐Ÿ“™:empty-bottom โ”œโ”€โ”€ ยท281da94 โ”œโ”€โ”€ ยท12995d7 โ””โ”€โ”€ ยท3d57fc1 @@ -3013,10 +3013,10 @@ mod ad_hoc_at_reference { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:1:empty-top[๐ŸŒณ] <> โœ“! on 281da94 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:1:empty-top[๐ŸŒณ] {1} - โ”œโ”€โ”€ ๐Ÿ“™:1:empty-top[๐ŸŒณ] - โ””โ”€โ”€ :0:main +โŒ‚:empty-top[๐ŸŒณ] <> โœ“! on 281da94 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:empty-top[๐ŸŒณ] {1} + โ”œโ”€โ”€ ๐Ÿ“™:empty-top[๐ŸŒณ] + โ””โ”€โ”€ :main โ”œโ”€โ”€ ยท281da94 โ”œโ”€โ”€ ยท12995d7 โ””โ”€โ”€ ยท3d57fc1 @@ -3096,11 +3096,11 @@ mod ad_hoc_at_reference { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:1:main[๐ŸŒณ] <> โœ“! on 281da94 -โ””โ”€โ”€ โ‰ก:1:main[๐ŸŒณ] {1} - โ”œโ”€โ”€ :1:main[๐ŸŒณ] - โ”œโ”€โ”€ ๐Ÿ“™:2:empty-middle - โ””โ”€โ”€ ๐Ÿ“™:0:empty-bottom +โŒ‚:main[๐ŸŒณ] <> โœ“! on 281da94 +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] {1} + โ”œโ”€โ”€ :main[๐ŸŒณ] + โ”œโ”€โ”€ ๐Ÿ“™:empty-middle + โ””โ”€โ”€ ๐Ÿ“™:empty-bottom โ”œโ”€โ”€ ยท281da94 โ”œโ”€โ”€ ยท12995d7 โ””โ”€โ”€ ยท3d57fc1 diff --git a/crates/but-workspace/tests/workspace/branch/move_branch.rs b/crates/but-workspace/tests/workspace/branch/move_branch.rs index 424ccd91896..3c9177d5d2b 100644 --- a/crates/but-workspace/tests/workspace/branch/move_branch.rs +++ b/crates/but-workspace/tests/workspace/branch/move_branch.rs @@ -39,14 +39,14 @@ fn move_top_branch_to_top_of_another_stack() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:4:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:B + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -86,14 +86,14 @@ fn move_top_branch_to_top_of_another_stack() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:C on 85efbe4 {1} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:3:C +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {1} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ”‚ โ””โ”€โ”€ ยทf2cc60d (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:A +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:B on 85efbe4 {2} - โ””โ”€โ”€ ๐Ÿ“™:5:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -166,14 +166,14 @@ fn move_bottom_branch_to_top_of_another_stack() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:4:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:B + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -212,14 +212,14 @@ fn move_bottom_branch_to_top_of_another_stack() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:B on 85efbe4 {1} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:3:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {1} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B โ”‚ โ”‚ โ””โ”€โ”€ ยทf9061ed (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:A +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:C on 85efbe4 {2} - โ””โ”€โ”€ ๐Ÿ“™:5:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} + โ””โ”€โ”€ ๐Ÿ“™:C โ””โ”€โ”€ ยท8e00332 (๐Ÿ˜๏ธ) "#]] @@ -257,14 +257,14 @@ fn move_single_branch_to_top_of_another_stack() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:4:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:B + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -301,13 +301,13 @@ fn move_single_branch_to_top_of_another_stack() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท148f8f3 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ๐Ÿ“™:4:C + โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:B + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -345,14 +345,14 @@ fn reorder_branch_in_stack() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:4:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:B + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -392,14 +392,14 @@ fn reorder_branch_in_stack() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:4:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:B โ”‚ โ””โ”€โ”€ ยทde0581e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:C + โ””โ”€โ”€ ๐Ÿ“™:C โ””โ”€โ”€ ยท8e00332 (๐Ÿ˜๏ธ) "#]] @@ -437,14 +437,14 @@ fn insert_branch_in_the_middle_of_a_stack() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:4:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:B + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -481,13 +481,13 @@ fn insert_branch_in_the_middle_of_a_stack() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:3:C +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยท3e7ff55 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ๐Ÿ“™:4:A + โ”œโ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท4dfe841 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:B + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -520,12 +520,12 @@ fn move_empty_branch() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 85efbe4 {2} - โ””โ”€โ”€ ๐Ÿ“™:4:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -559,10 +559,10 @@ fn move_empty_branch() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 85efbe4 {1} - โ”œโ”€โ”€ ๐Ÿ“™:4:B - โ””โ”€โ”€ ๐Ÿ“™:5:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {1} + โ”œโ”€โ”€ ๐Ÿ“™:B + โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] @@ -594,12 +594,12 @@ fn move_branch_on_top_of_empty_branch() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 85efbe4 {2} - โ””โ”€โ”€ ๐Ÿ“™:4:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -633,11 +633,11 @@ fn move_branch_on_top_of_empty_branch() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:4:B + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -674,10 +674,10 @@ fn move_empty_branch_on_top_of_empty_branch_in_same_stack() -> anyhow::Result<() snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 3183e43 {1} - โ”œโ”€โ”€ ๐Ÿ“™:4:B - โ””โ”€โ”€ ๐Ÿ“™:5:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {1} + โ”œโ”€โ”€ ๐Ÿ“™:B + โ””โ”€โ”€ ๐Ÿ“™:A "#]] ); @@ -699,10 +699,10 @@ fn move_empty_branch_on_top_of_empty_branch_in_same_stack() -> anyhow::Result<() snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:A on 3183e43 {1} - โ”œโ”€โ”€ ๐Ÿ“™:4:A - โ””โ”€โ”€ ๐Ÿ“™:5:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {1} + โ”œโ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -741,11 +741,11 @@ fn move_empty_branch_on_top_of_empty_branch_across_stacks() -> anyhow::Result<() snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:4:A on 3183e43 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:B on 3183e43 {2} - โ””โ”€โ”€ ๐Ÿ“™:5:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {2} + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -767,10 +767,10 @@ fn move_empty_branch_on_top_of_empty_branch_across_stacks() -> anyhow::Result<() snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:A on 3183e43 {2} - โ”œโ”€โ”€ ๐Ÿ“™:4:A - โ””โ”€โ”€ ๐Ÿ“™:5:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {2} + โ”œโ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -808,14 +808,14 @@ fn non_empty_move_updates_metadata_and_keeps_display_order_aligned() -> anyhow:: snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:4:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:B + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -850,14 +850,14 @@ fn non_empty_move_updates_metadata_and_keeps_display_order_aligned() -> anyhow:: snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:5:B on 85efbe4 {2} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:5:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B โ”‚ โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:C on 85efbe4 {1} - โ”œโ”€โ”€ ๐Ÿ“™:4:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {1} + โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยทf2cc60d (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:3:A + โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] @@ -868,14 +868,14 @@ fn non_empty_move_updates_metadata_and_keeps_display_order_aligned() -> anyhow:: snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:C on 85efbe4 {1} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:3:C +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {1} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ”‚ โ””โ”€โ”€ ยทf2cc60d (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:A +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:B on 85efbe4 {2} - โ””โ”€โ”€ ๐Ÿ“™:5:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -1021,12 +1021,12 @@ fn move_branch_when_base_segment_has_no_ref_name() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 85efbe4 {2} - โ””โ”€โ”€ ๐Ÿ“™:4:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -1062,11 +1062,11 @@ fn move_branch_when_base_segment_has_no_ref_name() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 85efbe4 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:B on 85efbe4 {1} - โ”œโ”€โ”€ ๐Ÿ“™:3:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 85efbe4 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {1} + โ”œโ”€โ”€ ๐Ÿ“™:B โ”‚ โ””โ”€โ”€ ยทf9061ed (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:4:A + โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] @@ -1109,12 +1109,12 @@ fn move_empty_branch_onto_non_empty_branch_with_advanced_target() -> anyhow::Res snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:B on 85efbe4 {2} - โ””โ”€โ”€ ๐Ÿ“™:5:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -1148,10 +1148,10 @@ fn move_empty_branch_onto_non_empty_branch_with_advanced_target() -> anyhow::Res snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 85efbe4 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:B on 85efbe4 {1} - โ”œโ”€โ”€ ๐Ÿ“™:5:B - โ””โ”€โ”€ ๐Ÿ“™:6:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 85efbe4 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {1} + โ”œโ”€โ”€ ๐Ÿ“™:B + โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] @@ -1191,12 +1191,12 @@ fn move_non_empty_branch_onto_empty_branch_with_advanced_target() -> anyhow::Res snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:B on 85efbe4 {2} - โ””โ”€โ”€ ๐Ÿ“™:5:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -1230,11 +1230,11 @@ fn move_non_empty_branch_onto_empty_branch_with_advanced_target() -> anyhow::Res snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 85efbe4 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 85efbe4 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:B + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); diff --git a/crates/but-workspace/tests/workspace/branch/remove_reference.rs b/crates/but-workspace/tests/workspace/branch/remove_reference.rs index 82125bcb6ed..64f9286d981 100644 --- a/crates/but-workspace/tests/workspace/branch/remove_reference.rs +++ b/crates/but-workspace/tests/workspace/branch/remove_reference.rs @@ -40,7 +40,7 @@ Single commit, no main remote/target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 "#]] ); @@ -87,7 +87,7 @@ Single commit, no main remote/target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 "#]] ); @@ -124,11 +124,11 @@ Single commit, target, no ws commit, but ws-reference and a named segment, and b snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on 3183e43 {0} - โ”œโ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {0} + โ”œโ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยทc2878fb (๐Ÿ˜๏ธ) โ–บA2 - โ””โ”€โ”€ :4:A1 + โ””โ”€โ”€ :A1 โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) "#]] @@ -155,9 +155,9 @@ Single commit, target, no ws commit, but ws-reference and a named segment, and b snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ””โ”€โ”€ โ‰ก:3:anon: on 3183e43 - โ””โ”€โ”€ :3:anon: +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ””โ”€โ”€ โ‰ก:anon: on 3183e43 + โ””โ”€โ”€ :anon: โ”œโ”€โ”€ ยทc2878fb (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) @@ -204,16 +204,16 @@ Two commits in main, target setup, ws commit, many more usable branches snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:A on bce0c5e {0} - โ”œโ”€โ”€ ๐Ÿ“™:5:A - โ”œโ”€โ”€ ๐Ÿ“™:6:A2-3 - โ”œโ”€โ”€ ๐Ÿ“™:7:A2-2 - โ”œโ”€โ”€ ๐Ÿ“™:8:A2-1 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on bce0c5e {0} + โ”œโ”€โ”€ ๐Ÿ“™:A + โ”œโ”€โ”€ ๐Ÿ“™:A2-3 + โ”œโ”€โ”€ ๐Ÿ“™:A2-2 + โ”œโ”€โ”€ ๐Ÿ“™:A2-1 โ”‚ โ””โ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ๐Ÿ“™:9:A1-1 - โ”œโ”€โ”€ ๐Ÿ“™:10:A1-2 - โ””โ”€โ”€ ๐Ÿ“™:11:A1-3 + โ”œโ”€โ”€ ๐Ÿ“™:A1-1 + โ”œโ”€โ”€ ๐Ÿ“™:A1-2 + โ””โ”€โ”€ ๐Ÿ“™:A1-3 โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) "#]] @@ -238,12 +238,12 @@ Two commits in main, target setup, ws commit, many more usable branches snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A1-1 on bce0c5e {0} - โ”œโ”€โ”€ ๐Ÿ“™:3:A1-1 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A1-1 on bce0c5e {0} + โ”œโ”€โ”€ ๐Ÿ“™:A1-1 โ”‚ โ””โ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ๐Ÿ“™:5:A1-2 - โ””โ”€โ”€ ๐Ÿ“™:6:A1-3 + โ”œโ”€โ”€ ๐Ÿ“™:A1-2 + โ””โ”€โ”€ ๐Ÿ“™:A1-3 โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) "#]] @@ -267,9 +267,9 @@ Two commits in main, target setup, ws commit, many more usable branches snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A1-3 on bce0c5e {0} - โ””โ”€โ”€ ๐Ÿ“™:3:A1-3 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A1-3 on bce0c5e {0} + โ””โ”€โ”€ ๐Ÿ“™:A1-3 โ”œโ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) @@ -326,14 +326,14 @@ Single commit, no main remote/target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:A on 3183e43 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:2:A -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:3:B -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:C -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:D on 3183e43 {1} - โ”œโ”€โ”€ ๐Ÿ“™:5:D - โ””โ”€โ”€ ๐Ÿ“™:6:E +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:D on 3183e43 {1} + โ”œโ”€โ”€ ๐Ÿ“™:D + โ””โ”€โ”€ ๐Ÿ“™:E "#]] ); @@ -354,13 +354,13 @@ Single commit, no main remote/target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:B on 3183e43 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:2:B -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:C -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:D on 3183e43 {1} - โ”œโ”€โ”€ ๐Ÿ“™:4:D - โ””โ”€โ”€ ๐Ÿ“™:5:E +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:D on 3183e43 {1} + โ”œโ”€โ”€ ๐Ÿ“™:D + โ””โ”€โ”€ ๐Ÿ“™:E "#]] ); @@ -377,14 +377,14 @@ Single commit, no main remote/target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:A on 3183e43 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:2:A -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:3:B -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:C -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:D on 3183e43 {1} - โ”œโ”€โ”€ ๐Ÿ“™:5:D - โ””โ”€โ”€ ๐Ÿ“™:6:E +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:D on 3183e43 {1} + โ”œโ”€โ”€ ๐Ÿ“™:D + โ””โ”€โ”€ ๐Ÿ“™:E "#]] ); @@ -407,13 +407,13 @@ Single commit, no main remote/target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:2:B on 3183e43 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:2:B -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:C -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:D on 3183e43 {1} - โ”œโ”€โ”€ ๐Ÿ“™:4:D - โ””โ”€โ”€ ๐Ÿ“™:5:E +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:D on 3183e43 {1} + โ”œโ”€โ”€ ๐Ÿ“™:D + โ””โ”€โ”€ ๐Ÿ“™:E "#]] ); @@ -464,7 +464,7 @@ Single commit, no main remote/target, no ws commit, but ws-reference snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 "#]] ); diff --git a/crates/but-workspace/tests/workspace/branch/tear_off_branch.rs b/crates/but-workspace/tests/workspace/branch/tear_off_branch.rs index c888ec68a53..c646de18a30 100644 --- a/crates/but-workspace/tests/workspace/branch/tear_off_branch.rs +++ b/crates/but-workspace/tests/workspace/branch/tear_off_branch.rs @@ -36,14 +36,14 @@ fn tear_off_top_most_branch() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:4:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:B + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -84,15 +84,15 @@ fn tear_off_top_most_branch() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:4:B on 85efbe4 {2} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:B +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B โ”‚ โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:C on 85efbe4 {3} - โ””โ”€โ”€ ๐Ÿ“™:5:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {3} + โ””โ”€โ”€ ๐Ÿ“™:C โ””โ”€โ”€ ยท8e00332 (๐Ÿ˜๏ธ) "#]] @@ -130,14 +130,14 @@ fn tear_off_bottom_most_branch() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:4:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:B + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -178,15 +178,15 @@ fn tear_off_bottom_most_branch() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:4:C on 85efbe4 {2} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:C +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยท8e00332 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:B on 85efbe4 {3} - โ””โ”€โ”€ ๐Ÿ“™:5:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {3} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -224,14 +224,14 @@ fn tear_off_only_branch_in_stack() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:4:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:B + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -271,14 +271,14 @@ fn tear_off_only_branch_in_stack() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:4:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:B + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -309,11 +309,11 @@ fn tear_off_from_single_stack_in_ws_top() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:4:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:B โ”‚ โ””โ”€โ”€ ยทd69fe94 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:3:A + โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] @@ -352,12 +352,12 @@ fn tear_off_from_single_stack_in_ws_top() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 85efbe4 {2} - โ””โ”€โ”€ ๐Ÿ“™:4:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยท1273ba9 (๐Ÿ˜๏ธ) "#]] @@ -388,11 +388,11 @@ fn tear_off_from_single_stack_in_ws_bottom() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:4:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:B โ”‚ โ””โ”€โ”€ ยทd69fe94 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:3:A + โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] @@ -431,12 +431,12 @@ fn tear_off_from_single_stack_in_ws_bottom() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:B on 85efbe4 {2} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B โ”‚ โ””โ”€โ”€ ยท1273ba9 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:A on 85efbe4 {1} - โ””โ”€โ”€ ๐Ÿ“™:4:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] @@ -468,10 +468,10 @@ fn tear_off_empty_branch() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 85efbe4 {1} - โ”œโ”€โ”€ ๐Ÿ“™:4:B - โ””โ”€โ”€ ๐Ÿ“™:5:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {1} + โ”œโ”€โ”€ ๐Ÿ“™:B + โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] @@ -509,12 +509,12 @@ fn tear_off_empty_branch() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 85efbe4 {3} - โ””โ”€โ”€ ๐Ÿ“™:4:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {3} + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -545,10 +545,10 @@ fn tear_off_non_empty_branch() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 85efbe4 {1} - โ”œโ”€โ”€ ๐Ÿ“™:4:B - โ””โ”€โ”€ ๐Ÿ“™:5:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {1} + โ”œโ”€โ”€ ๐Ÿ“™:B + โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] @@ -586,11 +586,11 @@ fn tear_off_non_empty_branch() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:4:B on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:B -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {3} - โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {3} + โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] diff --git a/crates/but-workspace/tests/workspace/commit/discard_commit.rs b/crates/but-workspace/tests/workspace/commit/discard_commit.rs index af85d23c1d0..01dd7d2e927 100644 --- a/crates/but-workspace/tests/workspace/commit/discard_commit.rs +++ b/crates/but-workspace/tests/workspace/commit/discard_commit.rs @@ -104,14 +104,14 @@ fn discard_tip_commit_in_workspace_stack() -> Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:4:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:B + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -123,13 +123,13 @@ fn discard_tip_commit_in_workspace_stack() -> Result<()> { snapbox::assert_data_eq!( graph_workspace(outcome.workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:5:C - โ””โ”€โ”€ ๐Ÿ“™:6:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:C + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -189,14 +189,14 @@ fn discard_bottom_commit_in_workspace_stack() -> Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:4:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:B + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -208,14 +208,14 @@ fn discard_bottom_commit_in_workspace_stack() -> Result<()> { snapbox::assert_data_eq!( graph_workspace(outcome.workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:4:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยท8e00332 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:B + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); diff --git a/crates/but-workspace/tests/workspace/commit/move_commit.rs b/crates/but-workspace/tests/workspace/commit/move_commit.rs index c9451581f99..22ffc1de3fe 100644 --- a/crates/but-workspace/tests/workspace/commit/move_commit.rs +++ b/crates/but-workspace/tests/workspace/commit/move_commit.rs @@ -47,14 +47,14 @@ fn move_top_commit_to_top_of_another_stack() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:4:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:B + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -114,14 +114,14 @@ fn move_top_commit_to_top_of_another_stack() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ”œโ”€โ”€ ยทf2cc60d (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:5:C - โ””โ”€โ”€ ๐Ÿ“™:6:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:C + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -159,14 +159,14 @@ fn move_bottom_commit_to_top_of_another_stack() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:4:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:B + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -228,15 +228,15 @@ fn move_bottom_commit_to_top_of_another_stack() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ”œโ”€โ”€ ยทf9061ed (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:4:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยท8e00332 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:B + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -273,14 +273,14 @@ fn move_top_commit_to_bottom_of_another_stack() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:4:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:B + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -340,14 +340,14 @@ fn move_top_commit_to_bottom_of_another_stack() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ”œโ”€โ”€ ยท2506923 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยท8e00332 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:5:C - โ””โ”€โ”€ ๐Ÿ“™:6:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:C + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -385,14 +385,14 @@ fn move_bottom_commit_to_bottom_of_another_stack() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:4:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:B + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -454,15 +454,15 @@ fn move_bottom_commit_to_bottom_of_another_stack() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ”œโ”€โ”€ ยท4dfe841 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:4:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยท8e00332 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:B + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -499,14 +499,14 @@ fn move_single_commit_to_the_top_of_another_branch() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:4:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:B + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -559,14 +559,14 @@ fn move_single_commit_to_the_top_of_another_branch() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:5:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:5:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:3:C +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ”œโ”€โ”€ ยท148f8f3 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:4:B + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -604,14 +604,14 @@ fn move_single_commit_to_the_bottom_of_another_branch() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:4:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:5:B + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -673,13 +673,13 @@ fn move_single_commit_to_the_bottom_of_another_branch() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:5:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:5:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:3:C +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยทad476a8 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:4:B + โ””โ”€โ”€ ๐Ÿ“™:B โ”œโ”€โ”€ ยทf9061ed (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) @@ -713,12 +713,12 @@ fn move_commit_to_empty_branch() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 85efbe4 {2} - โ””โ”€โ”€ ๐Ÿ“™:4:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -765,11 +765,11 @@ fn move_commit_to_empty_branch() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:4:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:B on 85efbe4 {2} - โ””โ”€โ”€ ๐Ÿ“™:3:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] @@ -797,13 +797,13 @@ fn move_commit_in_non_managed_workspace() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:three[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:three[๐ŸŒณ] {1} - โ”œโ”€โ”€ :0:three[๐ŸŒณ] +โŒ‚:three[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:three[๐ŸŒณ] {1} + โ”œโ”€โ”€ :three[๐ŸŒณ] โ”‚ โ””โ”€โ”€ ยทc9f444c - โ”œโ”€โ”€ :1:two <> origin/two โ†’:2: + โ”œโ”€โ”€ :two <> origin/two โ”‚ โ””โ”€โ”€ โ„๏ธ16fd221 - โ””โ”€โ”€ :3:one + โ””โ”€โ”€ :one โ””โ”€โ”€ โ„8b426d0 "#]] @@ -854,12 +854,12 @@ fn move_commit_in_non_managed_workspace() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:three[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:three[๐ŸŒณ] {1} - โ”œโ”€โ”€ :0:three[๐ŸŒณ] +โŒ‚:three[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:three[๐ŸŒณ] {1} + โ”œโ”€โ”€ :three[๐ŸŒณ] โ”‚ โ”œโ”€โ”€ ยทc9f444c โ–บtwo โ”‚ โ””โ”€โ”€ ยท16fd221 - โ””โ”€โ”€ :3:one + โ””โ”€โ”€ :one โ””โ”€โ”€ ยท8b426d0 "#]] @@ -895,13 +895,13 @@ fn reorder_merge_commit_above_keeps_child_commits_visible() -> anyhow::Result<() snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:child-stack[๐ŸŒณ] <> โœ“refs/remotes/origin/main on aa67ae0 -โ””โ”€โ”€ โ‰ก:0:child-stack[๐ŸŒณ] on aa67ae0 {1} - โ”œโ”€โ”€ :0:child-stack[๐ŸŒณ] +โŒ‚:child-stack[๐ŸŒณ] <> โœ“refs/remotes/origin/main on aa67ae0 +โ””โ”€โ”€ โ‰ก:child-stack[๐ŸŒณ] on aa67ae0 {1} + โ”œโ”€โ”€ :child-stack[๐ŸŒณ] โ”‚ โ””โ”€โ”€ ยท32c8bda โ–บC2 - โ”œโ”€โ”€ :3:C1 + โ”œโ”€โ”€ :C1 โ”‚ โ””โ”€โ”€ ยท64dace5 - โ””โ”€โ”€ :4:M + โ””โ”€โ”€ :M โ””โ”€โ”€ ยท197bdf1 "#]] @@ -981,13 +981,13 @@ fn reorder_merge_commit_below_keeps_child_commits_visible() -> anyhow::Result<() snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:child-stack[๐ŸŒณ] <> โœ“refs/remotes/origin/main on aa67ae0 -โ””โ”€โ”€ โ‰ก:0:child-stack[๐ŸŒณ] on aa67ae0 {1} - โ”œโ”€โ”€ :0:child-stack[๐ŸŒณ] +โŒ‚:child-stack[๐ŸŒณ] <> โœ“refs/remotes/origin/main on aa67ae0 +โ””โ”€โ”€ โ‰ก:child-stack[๐ŸŒณ] on aa67ae0 {1} + โ”œโ”€โ”€ :child-stack[๐ŸŒณ] โ”‚ โ””โ”€โ”€ ยท32c8bda โ–บC2 - โ”œโ”€โ”€ :3:C1 + โ”œโ”€โ”€ :C1 โ”‚ โ””โ”€โ”€ ยท64dace5 - โ””โ”€โ”€ :4:M + โ””โ”€โ”€ :M โ””โ”€โ”€ ยท197bdf1 "#]] @@ -1058,13 +1058,13 @@ fn reorder_commit_in_non_managed_workspace() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:three[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:three[๐ŸŒณ] {1} - โ”œโ”€โ”€ :0:three[๐ŸŒณ] +โŒ‚:three[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:three[๐ŸŒณ] {1} + โ”œโ”€โ”€ :three[๐ŸŒณ] โ”‚ โ””โ”€โ”€ ยทc9f444c - โ”œโ”€โ”€ :1:two <> origin/two โ†’:2: + โ”œโ”€โ”€ :two <> origin/two โ”‚ โ””โ”€โ”€ โ„๏ธ16fd221 - โ””โ”€โ”€ :3:one + โ””โ”€โ”€ :one โ””โ”€โ”€ โ„8b426d0 "#]] @@ -1124,12 +1124,12 @@ fn reorder_commit_in_non_managed_workspace() -> anyhow::Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:three[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:0:three[๐ŸŒณ] {1} - โ”œโ”€โ”€ :0:three[๐ŸŒณ] +โŒ‚:three[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:three[๐ŸŒณ] {1} + โ”œโ”€โ”€ :three[๐ŸŒณ] โ”‚ โ”œโ”€โ”€ ยท09ad3ca โ–บtwo โ”‚ โ””โ”€โ”€ ยท0c38dd9 - โ””โ”€โ”€ :2:one + โ””โ”€โ”€ :one โ””โ”€โ”€ ยท8b426d0 "#]] diff --git a/crates/but-workspace/tests/workspace/commit/squash_commits.rs b/crates/but-workspace/tests/workspace/commit/squash_commits.rs index acb20953ad4..4dba24518aa 100644 --- a/crates/but-workspace/tests/workspace/commit/squash_commits.rs +++ b/crates/but-workspace/tests/workspace/commit/squash_commits.rs @@ -488,12 +488,12 @@ fn squash_across_stacks_subject_into_target() -> Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 85efbe4 {2} - โ””โ”€โ”€ ๐Ÿ“™:4:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -533,11 +533,11 @@ fn squash_across_stacks_subject_into_target() -> Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:4:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:B on 85efbe4 {2} - โ””โ”€โ”€ ๐Ÿ“™:3:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทa2dc4c7 (๐Ÿ˜๏ธ) "#]] @@ -574,12 +574,12 @@ fn squash_across_stacks_target_into_subject() -> Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 85efbe4 {2} - โ””โ”€โ”€ ๐Ÿ“™:4:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] @@ -619,12 +619,12 @@ fn squash_across_stacks_target_into_subject() -> Result<()> { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท80db672 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 85efbe4 {2} - โ””โ”€โ”€ ๐Ÿ“™:4:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); diff --git a/crates/but-workspace/tests/workspace/ref_info/mod.rs b/crates/but-workspace/tests/workspace/ref_info/mod.rs index d32c2e8803e..01d6cfc1228 100644 --- a/crates/but-workspace/tests/workspace/ref_info/mod.rs +++ b/crates/but-workspace/tests/workspace/ref_info/mod.rs @@ -166,7 +166,6 @@ RefInfo { base: None, segments: [ ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "โ–บmain[๐ŸŒณ]", remote_tracking_ref_name: "None", commits: [], @@ -274,7 +273,6 @@ RefInfo { base: None, segments: [ ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "None", remote_tracking_ref_name: "None", commits: [ @@ -354,7 +352,6 @@ RefInfo { base: None, segments: [ ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "โ–บmain[๐ŸŒณ]", remote_tracking_ref_name: "None", commits: [ @@ -489,7 +486,6 @@ RefInfo { base: None, segments: [ ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "โ–บmain[๐ŸŒณ]", remote_tracking_ref_name: "None", commits: [ @@ -633,7 +629,6 @@ RefInfo { base: None, segments: [ ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "โ–บmain[๐ŸŒณ]", remote_tracking_ref_name: "None", commits: [ @@ -646,7 +641,6 @@ RefInfo { base: "344e320", }, ref_info::ui::Segment { - id: NodeIndex(1), ref_name: "โ–บnine", remote_tracking_ref_name: "None", commits: [ @@ -661,7 +655,6 @@ RefInfo { base: "c4f2a35", }, ref_info::ui::Segment { - id: NodeIndex(2), ref_name: "โ–บsix", remote_tracking_ref_name: "None", commits: [ @@ -676,7 +669,6 @@ RefInfo { base: "281da94", }, ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บthree", remote_tracking_ref_name: "None", commits: [ @@ -690,7 +682,6 @@ RefInfo { base: "3d57fc1", }, ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "โ–บone", remote_tracking_ref_name: "None", commits: [ diff --git a/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/journey/exhaustive_with_squash_merges.rs b/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/journey/exhaustive_with_squash_merges.rs index 75c700e4526..6f813911814 100644 --- a/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/journey/exhaustive_with_squash_merges.rs +++ b/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/journey/exhaustive_with_squash_merges.rs @@ -56,7 +56,6 @@ Ok( base: None, segments: [ ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "โ–บmain[๐ŸŒณ]", remote_tracking_ref_name: "None", commits: [], @@ -133,7 +132,6 @@ Ok( base: None, segments: [ ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "โ–บmain[๐ŸŒณ]", remote_tracking_ref_name: "None", commits: [], @@ -150,7 +148,6 @@ Ok( target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(0), }, ), lower_bound: Some( @@ -221,7 +218,6 @@ Ok( base: None, segments: [ ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "โ–บmain[๐ŸŒณ]", remote_tracking_ref_name: "refs/remotes/origin/main", commits: [], @@ -239,14 +235,12 @@ Ok( ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(0), }, ), lower_bound: Some( @@ -300,7 +294,6 @@ Ok( base: None, segments: [ ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "โ–บmain[๐ŸŒณ]", remote_tracking_ref_name: "refs/remotes/origin/main", commits: [], @@ -318,14 +311,12 @@ Ok( ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(0), }, ), lower_bound: Some( @@ -395,14 +386,12 @@ Ok( ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -477,7 +466,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บS1", remote_tracking_ref_name: "None", commits: [], @@ -495,14 +483,12 @@ Ok( ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -574,7 +560,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บS1", remote_tracking_ref_name: "None", commits: [ @@ -594,14 +579,12 @@ Ok( ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -654,7 +637,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บS1", remote_tracking_ref_name: "None", commits: [ @@ -674,14 +656,12 @@ Ok( ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -756,7 +736,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บS1", remote_tracking_ref_name: "refs/remotes/origin/S1", commits: [ @@ -776,14 +755,12 @@ Ok( ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -862,7 +839,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บS1", remote_tracking_ref_name: "refs/remotes/origin/S1", commits: [ @@ -883,14 +859,12 @@ Ok( ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -968,7 +942,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บS1", remote_tracking_ref_name: "refs/remotes/origin/S1", commits: [ @@ -989,14 +962,12 @@ Ok( ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -1079,7 +1050,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บS1", remote_tracking_ref_name: "refs/remotes/origin/S1", commits: [ @@ -1100,14 +1070,12 @@ Ok( ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 2, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -1200,7 +1168,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บS1", remote_tracking_ref_name: "refs/remotes/origin/S1", commits: [ @@ -1224,14 +1191,12 @@ Ok( ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 5, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(adc9f0cd07bd0a09363ac6536291bf821ca845c4), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -1330,7 +1295,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บS1", remote_tracking_ref_name: "refs/remotes/origin/S1", commits: [ @@ -1355,7 +1319,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(5), ref_name: "โ–บlocal", remote_tracking_ref_name: "None", commits: [ @@ -1368,7 +1331,6 @@ Ok( base: "de02b20", }, ref_info::ui::Segment { - id: NodeIndex(6), ref_name: "โ–บlocal-bottom", remote_tracking_ref_name: "None", commits: [ @@ -1388,14 +1350,12 @@ Ok( ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 7, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(35faa22c8d0a01ba45da3971406eab6932b1bbde), - segment_index: NodeIndex(2), }, ), lower_bound: Some( diff --git a/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/journey/integrate_with_merges.rs b/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/journey/integrate_with_merges.rs index a0875d5bd3a..edff76f1f42 100644 --- a/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/journey/integrate_with_merges.rs +++ b/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/journey/integrate_with_merges.rs @@ -70,7 +70,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บA", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -91,14 +90,12 @@ Ok( ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -175,7 +172,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บA", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -196,14 +192,12 @@ Ok( ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 2, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -280,7 +274,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บA", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -303,14 +296,12 @@ Ok( ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -396,7 +387,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(5), ref_name: "โ–บA", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -417,14 +407,12 @@ Ok( ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 2, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(085089cbf8a35fa549a5d50bd74930a7fddf970d), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -497,7 +485,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บA", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -518,14 +505,12 @@ Ok( ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -608,7 +593,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(5), ref_name: "โ–บA", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -629,14 +613,12 @@ Ok( ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 1, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(a670cd571f1a6946a1a87d107e909445aa0fe90d), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -710,7 +692,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บA", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -732,14 +713,12 @@ Ok( ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -817,7 +796,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "โ–บA", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -837,14 +815,12 @@ Ok( ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 1, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(a62b0de7d50898e05c6cfa5b56d268aa5be17087), - segment_index: NodeIndex(2), }, ), lower_bound: Some( diff --git a/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/journey/integrate_with_rebase.rs b/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/journey/integrate_with_rebase.rs index a043938a5fc..2d69ef37c5a 100644 --- a/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/journey/integrate_with_rebase.rs +++ b/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/journey/integrate_with_rebase.rs @@ -70,7 +70,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บA", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -91,14 +90,12 @@ Ok( ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 5, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(eabf2989a998260c7fbe181b33d5772705d62907), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -180,7 +177,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บA", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -201,14 +197,12 @@ Ok( ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 5, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(d89aadb67d5c32e6a63cad3d36020b5e8e192a91), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -287,7 +281,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บA", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -308,14 +301,12 @@ Ok( ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -397,7 +388,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บA", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -418,14 +408,12 @@ Ok( ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 5, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(7a2d071f19ec7551996099943167460ff2c2dd9d), - segment_index: NodeIndex(2), }, ), lower_bound: Some( diff --git a/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/legacy.rs b/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/legacy.rs index 55a6d06c03b..c7ac169c60a 100644 --- a/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/legacy.rs +++ b/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/legacy.rs @@ -594,14 +594,14 @@ StackDetails { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B โ†’:1: {1} - โ”œโ”€โ”€ ๐Ÿ“™:4:B โ†’:1: +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B {1} + โ”œโ”€โ”€ ๐Ÿ“™:B โ”‚ โ”œโ”€โ”€ ยทcc0bf57* โ”‚ โ””โ”€โ”€ ยทd69fe94 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ๐Ÿ“™:2:A + โ”œโ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :3:main + โ””โ”€โ”€ :main โ””โ”€โ”€ ยท85efbe4 (๐Ÿ˜๏ธ) "#]] @@ -609,7 +609,7 @@ StackDetails { snapbox::assert_data_eq!( ws.to_debug(), snapbox::str![[r#" -Workspace(๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“!) { +Workspace(๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“!) { id: 0, kind: Managed { ref_info: RefInfo { @@ -628,9 +628,9 @@ Workspace(๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“!) { }, }, stacks: [ - Stack(โ‰ก๐Ÿ“™:4:B โ†’:1: {1}) { + Stack(โ‰ก๐Ÿ“™:B {1}) { segments: [ - StackSegment(๐Ÿ“™:4:B โ†’:1:) { + StackSegment(๐Ÿ“™:B) { commits: [ "ยทd69fe94 (๐Ÿ˜\u{fe0f})", ], @@ -641,14 +641,14 @@ Workspace(๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“!) { ], ), }, - StackSegment(๐Ÿ“™:2:A) { + StackSegment(๐Ÿ“™:A) { commits: [ "ยท09d8e52 (๐Ÿ˜\u{fe0f})", ], commits_on_remote: [], commits_outside: None, }, - StackSegment(:3:main) { + StackSegment(:main) { commits: [ "ยท85efbe4 (๐Ÿ˜\u{fe0f})", ], @@ -729,7 +729,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(5), ref_name: "โ–บB", remote_tracking_ref_name: "None", commits: [ @@ -746,7 +745,6 @@ RefInfo { base: "09d8e52", }, ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "โ–บA", remote_tracking_ref_name: "None", commits: [ @@ -766,14 +764,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(85efbe4d5a663bff0ed8fb5fbc38a72be0592f55), - segment_index: NodeIndex(2), }, ), lower_bound: Some( diff --git a/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/mod.rs b/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/mod.rs index 54e2fc0e728..2b9b7ee5711 100644 --- a/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/mod.rs +++ b/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/mod.rs @@ -238,7 +238,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บA", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -260,14 +259,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -319,7 +316,6 @@ RefInfo { ), segments: [ ๐Ÿ‘‰ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "โ–บA", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -341,14 +337,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(2), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), - segment_index: NodeIndex(3), }, ), lower_bound: Some( @@ -404,7 +398,6 @@ RefInfo { ), segments: [ ๐Ÿ‘‰ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "โ–บA", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -426,14 +419,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(2), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), - segment_index: NodeIndex(3), }, ), lower_bound: Some( @@ -505,7 +496,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บB-on-A", remote_tracking_ref_name: "refs/remotes/origin/B-on-A", commits: [ @@ -518,7 +508,6 @@ RefInfo { base: "f504e38", }, ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "โ–บA", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -539,14 +528,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -692,7 +679,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บB-on-A", remote_tracking_ref_name: "refs/remotes/origin/B-on-A", commits: [ @@ -705,7 +691,6 @@ RefInfo { base: "f504e38", }, ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "โ–บA", remote_tracking_ref_name: "None", commits: [ @@ -718,7 +703,6 @@ RefInfo { base: "807f596", }, ref_info::ui::Segment { - id: NodeIndex(6), ref_name: "โ–บbase-of-A", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -738,14 +722,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -820,7 +802,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บB-on-A", remote_tracking_ref_name: "refs/remotes/origin/B-on-A", commits: [ @@ -833,7 +814,6 @@ RefInfo { base: "0ee3a9e", }, ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "โ–บA", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -853,14 +833,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 1, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -944,7 +922,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บB-on-A", remote_tracking_ref_name: "refs/remotes/origin/B-on-A", commits: [ @@ -964,14 +941,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 1, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(a455fe761e758d0b6c0aa8966d91f2de32fa7bfc), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -1027,7 +1002,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "โ–บB-on-A", remote_tracking_ref_name: "refs/remotes/origin/B-on-A", commits: [ @@ -1040,7 +1014,6 @@ RefInfo { base: "0ee3a9e", }, ref_info::ui::Segment { - id: NodeIndex(5), ref_name: "โ–บA", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -1062,14 +1035,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 1, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(3), }, ), lower_bound: Some( @@ -1138,7 +1109,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บB", remote_tracking_ref_name: "None", commits: [ @@ -1151,7 +1121,6 @@ RefInfo { base: "1818c17", }, ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "โ–บA", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -1171,14 +1140,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 1, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(281456a55524d78e1e0ecab946032423aec1abe8), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -1228,7 +1195,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บB", remote_tracking_ref_name: "None", commits: [ @@ -1241,7 +1207,6 @@ RefInfo { base: "1818c17", }, ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "โ–บA", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -1261,14 +1226,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 1, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(281456a55524d78e1e0ecab946032423aec1abe8), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -1343,7 +1306,6 @@ RefInfo { ), segments: [ ๐Ÿ‘‰ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "โ–บA", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -1368,14 +1330,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(2), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), - segment_index: NodeIndex(3), }, ), lower_bound: Some( @@ -1451,7 +1411,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(5), ref_name: "โ–บlane", remote_tracking_ref_name: "None", commits: [], @@ -1472,7 +1431,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บadvanced-lane-2", remote_tracking_ref_name: "None", commits: [ @@ -1495,7 +1453,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "โ–บadvanced-lane", remote_tracking_ref_name: "None", commits: [ @@ -1515,14 +1472,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -1605,7 +1560,6 @@ RefInfo { ), segments: [ ๐Ÿ‘‰ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "โ–บlane", remote_tracking_ref_name: "None", commits: [ @@ -1618,7 +1572,6 @@ RefInfo { base: "None", }, ref_info::ui::Segment { - id: NodeIndex(7), ref_name: "โ–บlane-segment-01", remote_tracking_ref_name: "None", commits: [], @@ -1629,7 +1582,6 @@ RefInfo { base: "None", }, ref_info::ui::Segment { - id: NodeIndex(8), ref_name: "โ–บlane-segment-02", remote_tracking_ref_name: "None", commits: [], @@ -1650,7 +1602,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "โ–บlane-2", remote_tracking_ref_name: "None", commits: [], @@ -1661,7 +1612,6 @@ RefInfo { base: "None", }, ref_info::ui::Segment { - id: NodeIndex(5), ref_name: "โ–บlane-2-segment-01", remote_tracking_ref_name: "None", commits: [], @@ -1672,7 +1622,6 @@ RefInfo { base: "None", }, ref_info::ui::Segment { - id: NodeIndex(6), ref_name: "โ–บlane-2-segment-02", remote_tracking_ref_name: "None", commits: [], @@ -1690,14 +1639,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(2), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(3), }, ), lower_bound: Some( @@ -1763,7 +1710,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "โ–บlane-2", remote_tracking_ref_name: "None", commits: [], @@ -1774,7 +1720,6 @@ RefInfo { base: "None", }, ref_info::ui::Segment { - id: NodeIndex(5), ref_name: "โ–บlane-2-segment-01", remote_tracking_ref_name: "None", commits: [], @@ -1785,7 +1730,6 @@ RefInfo { base: "None", }, ref_info::ui::Segment { - id: NodeIndex(6), ref_name: "โ–บlane-2-segment-02", remote_tracking_ref_name: "None", commits: [], @@ -1806,7 +1750,6 @@ RefInfo { ), segments: [ ๐Ÿ‘‰ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "โ–บlane", remote_tracking_ref_name: "None", commits: [ @@ -1819,7 +1762,6 @@ RefInfo { base: "None", }, ref_info::ui::Segment { - id: NodeIndex(7), ref_name: "โ–บlane-segment-01", remote_tracking_ref_name: "None", commits: [], @@ -1830,7 +1772,6 @@ RefInfo { base: "None", }, ref_info::ui::Segment { - id: NodeIndex(8), ref_name: "โ–บlane-segment-02", remote_tracking_ref_name: "None", commits: [], @@ -1848,14 +1789,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(2), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(3), }, ), lower_bound: Some( @@ -1927,7 +1866,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บadvanced-lane", remote_tracking_ref_name: "None", commits: [ @@ -1950,7 +1888,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "โ–บlane", remote_tracking_ref_name: "None", commits: [], @@ -1968,14 +1905,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -2045,7 +1980,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บadvanced-lane", remote_tracking_ref_name: "refs/remotes/origin/advanced-lane", commits: [ @@ -2065,14 +1999,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -2150,7 +2082,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(5), ref_name: "โ–บdependent", remote_tracking_ref_name: "None", commits: [], @@ -2161,7 +2092,6 @@ RefInfo { base: "cbc6713", }, ref_info::ui::Segment { - id: NodeIndex(6), ref_name: "โ–บadvanced-lane", remote_tracking_ref_name: "refs/remotes/origin/advanced-lane", commits: [ @@ -2181,14 +2111,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -2249,7 +2177,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(5), ref_name: "โ–บadvanced-lane", remote_tracking_ref_name: "refs/remotes/origin/advanced-lane", commits: [], @@ -2260,7 +2187,6 @@ RefInfo { base: "cbc6713", }, ref_info::ui::Segment { - id: NodeIndex(6), ref_name: "โ–บdependent", remote_tracking_ref_name: "None", commits: [ @@ -2280,14 +2206,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -2362,7 +2286,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(5), ref_name: "โ–บon-top-of-dependent", remote_tracking_ref_name: "None", commits: [], @@ -2373,7 +2296,6 @@ RefInfo { base: "None", }, ref_info::ui::Segment { - id: NodeIndex(6), ref_name: "โ–บdependent", remote_tracking_ref_name: "None", commits: [], @@ -2384,7 +2306,6 @@ RefInfo { base: "cbc6713", }, ref_info::ui::Segment { - id: NodeIndex(7), ref_name: "โ–บadvanced-lane", remote_tracking_ref_name: "refs/remotes/origin/advanced-lane", commits: [ @@ -2404,14 +2325,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -2470,7 +2389,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(5), ref_name: "โ–บdependent", remote_tracking_ref_name: "None", commits: [], @@ -2481,7 +2399,6 @@ RefInfo { base: "None", }, ref_info::ui::Segment { - id: NodeIndex(6), ref_name: "โ–บon-top-of-dependent", remote_tracking_ref_name: "None", commits: [], @@ -2492,7 +2409,6 @@ RefInfo { base: "cbc6713", }, ref_info::ui::Segment { - id: NodeIndex(7), ref_name: "โ–บadvanced-lane", remote_tracking_ref_name: "refs/remotes/origin/advanced-lane", commits: [ @@ -2512,14 +2428,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -2593,7 +2507,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บon-top-of-lane", remote_tracking_ref_name: "refs/remotes/origin/on-top-of-lane", commits: [ @@ -2606,7 +2519,6 @@ RefInfo { base: "cbc6713", }, ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "โ–บlane", remote_tracking_ref_name: "refs/remotes/origin/lane", commits: [ @@ -2626,14 +2538,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -2707,7 +2617,6 @@ RefInfo { ), segments: [ ๐Ÿ‘‰ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "โ–บA", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -2721,7 +2630,6 @@ RefInfo { base: "f15ca75", }, ref_info::ui::Segment { - id: NodeIndex(5), ref_name: "โ–บintegrated", remote_tracking_ref_name: "None", commits: [ @@ -2742,14 +2650,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(2), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(3), }, ), lower_bound: Some( @@ -2818,7 +2724,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บadvanced-lane", remote_tracking_ref_name: "None", commits: [ @@ -2841,7 +2746,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "โ–บlane", remote_tracking_ref_name: "None", commits: [], @@ -2859,14 +2763,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -2921,7 +2823,6 @@ RefInfo { ), segments: [ ๐Ÿ‘‰ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "โ–บadvanced-lane", remote_tracking_ref_name: "None", commits: [ @@ -2944,7 +2845,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "โ–บlane", remote_tracking_ref_name: "None", commits: [], @@ -2962,14 +2862,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(2), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(3), }, ), lower_bound: Some( @@ -3043,7 +2941,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "โ–บlane", remote_tracking_ref_name: "None", commits: [], @@ -3064,7 +2961,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บadvanced-lane", remote_tracking_ref_name: "None", commits: [ @@ -3084,14 +2980,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 1, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -3144,7 +3038,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "โ–บlane", remote_tracking_ref_name: "None", commits: [], @@ -3165,7 +3058,6 @@ RefInfo { ), segments: [ ๐Ÿ‘‰ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "โ–บadvanced-lane", remote_tracking_ref_name: "None", commits: [ @@ -3185,14 +3077,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(2), commits_ahead: 1, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(3), }, ), lower_bound: Some( @@ -3240,7 +3130,6 @@ RefInfo { ), segments: [ ๐Ÿ‘‰ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "โ–บlane", remote_tracking_ref_name: "None", commits: [], @@ -3261,7 +3150,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บadvanced-lane", remote_tracking_ref_name: "None", commits: [ @@ -3281,14 +3169,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(2), commits_ahead: 1, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(0), }, ), lower_bound: Some( @@ -3342,7 +3228,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บadvanced-lane", remote_tracking_ref_name: "None", commits: [ @@ -3365,7 +3250,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "โ–บlane", remote_tracking_ref_name: "None", commits: [], @@ -3383,14 +3267,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 1, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -3455,7 +3337,6 @@ RefInfo { base: None, segments: [ ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "โ–บdisjoint[๐ŸŒณ]", remote_tracking_ref_name: "None", commits: [], @@ -3473,14 +3354,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(2), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(1), }, ), lower_bound: Some( @@ -3555,7 +3434,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บC-on-A", remote_tracking_ref_name: "None", commits: [ @@ -3568,7 +3446,6 @@ RefInfo { base: "d79bba9", }, ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "โ–บA", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -3591,7 +3468,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(6), ref_name: "โ–บB-on-A", remote_tracking_ref_name: "None", commits: [ @@ -3604,7 +3480,6 @@ RefInfo { base: "d79bba9", }, ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "โ–บA", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -3626,14 +3501,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -3685,7 +3558,6 @@ RefInfo { ), segments: [ ๐Ÿ‘‰ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "โ–บC-on-A", remote_tracking_ref_name: "None", commits: [ @@ -3698,7 +3570,6 @@ RefInfo { base: "d79bba9", }, ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "โ–บA", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -3721,7 +3592,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(6), ref_name: "โ–บB-on-A", remote_tracking_ref_name: "None", commits: [ @@ -3734,7 +3604,6 @@ RefInfo { base: "d79bba9", }, ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "โ–บA", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -3756,14 +3625,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(2), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), - segment_index: NodeIndex(3), }, ), lower_bound: Some( @@ -3815,7 +3682,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "โ–บC-on-A", remote_tracking_ref_name: "None", commits: [ @@ -3828,7 +3694,6 @@ RefInfo { base: "d79bba9", }, ref_info::ui::Segment { - id: NodeIndex(5), ref_name: "โ–บA", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -3851,7 +3716,6 @@ RefInfo { ), segments: [ ๐Ÿ‘‰ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "โ–บB-on-A", remote_tracking_ref_name: "None", commits: [ @@ -3864,7 +3728,6 @@ RefInfo { base: "d79bba9", }, ref_info::ui::Segment { - id: NodeIndex(5), ref_name: "โ–บA", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -3886,14 +3749,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(2), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), - segment_index: NodeIndex(3), }, ), lower_bound: Some( @@ -3946,7 +3807,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "โ–บC-on-A", remote_tracking_ref_name: "None", commits: [ @@ -3959,7 +3819,6 @@ RefInfo { base: "d79bba9", }, ๐Ÿ‘‰ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "โ–บA", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -3982,7 +3841,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(6), ref_name: "โ–บB-on-A", remote_tracking_ref_name: "None", commits: [ @@ -3995,7 +3853,6 @@ RefInfo { base: "d79bba9", }, ๐Ÿ‘‰ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "โ–บA", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -4017,14 +3874,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(2), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), - segment_index: NodeIndex(3), }, ), lower_bound: Some( @@ -4091,7 +3946,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บunrelated", remote_tracking_ref_name: "None", commits: [], @@ -4109,14 +3963,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -4166,7 +4018,6 @@ RefInfo { ), segments: [ ๐Ÿ‘‰ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "โ–บunrelated", remote_tracking_ref_name: "None", commits: [], @@ -4184,14 +4035,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(2), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), - segment_index: NodeIndex(0), }, ), lower_bound: Some( @@ -4240,14 +4089,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(1), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), - segment_index: NodeIndex(2), }, ), lower_bound: Some( @@ -4291,7 +4138,6 @@ RefInfo { base: None, segments: [ ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "โ–บunrelated", remote_tracking_ref_name: "None", commits: [], @@ -4309,14 +4155,12 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - segment_index: NodeIndex(2), commits_ahead: 0, }, ), target_commit: Some( TargetCommit { commit_id: Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), - segment_index: NodeIndex(0), }, ), lower_bound: None, diff --git a/crates/but-workspace/tests/workspace/upstream_integration.rs b/crates/but-workspace/tests/workspace/upstream_integration.rs index 043f0bb11e1..e52cd0e5c13 100644 --- a/crates/but-workspace/tests/workspace/upstream_integration.rs +++ b/crates/but-workspace/tests/workspace/upstream_integration.rs @@ -256,15 +256,15 @@ fn diamond_partially_content_integrated_rebase() -> Result<()> { snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/masterโ‡ฃ4 on 85aa44b -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:E on 85aa44b {1} - โ”œโ”€โ”€ ๐Ÿ“™:4:E +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/masterโ‡ฃ4 on 85aa44b +โ””โ”€โ”€ โ‰ก๐Ÿ“™:E on 85aa44b {1} + โ”œโ”€โ”€ ๐Ÿ“™:E โ”‚ โ””โ”€โ”€ ยทa6588cf (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ :6:C + โ”œโ”€โ”€ :C โ”‚ โ””โ”€โ”€ ยท4827d2f (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ :7:B + โ”œโ”€โ”€ :B โ”‚ โ””โ”€โ”€ ยท3d3bfa7 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :9:A + โ””โ”€โ”€ :A โ””โ”€โ”€ ยทf5b02d3 (๐Ÿ˜๏ธ) "#]] @@ -439,11 +439,11 @@ fn integrated_bottom_branch_no_workspace_rebase() -> Result<()> { snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -โŒ‚:1:A[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ2 on 3183e43 -โ””โ”€โ”€ โ‰ก:1:A[๐ŸŒณ] on 3183e43 {1} - โ”œโ”€โ”€ :1:A[๐ŸŒณ] +โŒ‚:A[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ2 on 3183e43 +โ””โ”€โ”€ โ‰ก:A[๐ŸŒณ] on 3183e43 {1} + โ”œโ”€โ”€ :A[๐ŸŒณ] โ”‚ โ””โ”€โ”€ ยทe792f40 - โ””โ”€โ”€ :3:B + โ””โ”€โ”€ :B โ””โ”€โ”€ ยทb38b04b (โœ“) "#]] @@ -598,11 +598,11 @@ fn integrated_bottom_branch_no_workspace_merge() -> Result<()> { snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -โŒ‚:1:A[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ2 on 3183e43 -โ””โ”€โ”€ โ‰ก:1:A[๐ŸŒณ] on 3183e43 {1} - โ”œโ”€โ”€ :1:A[๐ŸŒณ] +โŒ‚:A[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ2 on 3183e43 +โ””โ”€โ”€ โ‰ก:A[๐ŸŒณ] on 3183e43 {1} + โ”œโ”€โ”€ :A[๐ŸŒณ] โ”‚ โ””โ”€โ”€ ยทe792f40 - โ””โ”€โ”€ :3:B + โ””โ”€โ”€ :B โ””โ”€โ”€ ยทb38b04b (โœ“) "#]] @@ -803,12 +803,12 @@ fn fully_historically_integrated_branch_leaves_workspace_shape() -> Result<()> { snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:4:A on 3183e43 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท905d6e5 (๐Ÿ˜๏ธ|โœ“) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:B on 3183e43 {2} - โ””โ”€โ”€ ๐Ÿ“™:3:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {2} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทb38b04b (๐Ÿ˜๏ธ) "#]] @@ -836,9 +836,9 @@ fn fully_historically_integrated_branch_leaves_workspace_shape() -> Result<()> { snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 905d6e5 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:B on 905d6e5 {2} - โ””โ”€โ”€ ๐Ÿ“™:3:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 905d6e5 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 905d6e5 {2} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc932222 (๐Ÿ˜๏ธ) "#]] @@ -894,9 +894,9 @@ fn fully_integrated_single_branch_leaves_workspace_shape() -> Result<()> { snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on 3183e43 {1} - โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {1} + โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ ยท905d6e5 (๐Ÿ˜๏ธ|โœ“) "#]] @@ -918,7 +918,7 @@ fn fully_integrated_single_branch_leaves_workspace_shape() -> Result<()> { snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 905d6e5 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 905d6e5 "#]] ); @@ -975,9 +975,9 @@ fn fully_integrated_single_branch_reparents_workspace_commit_to_advanced_target( snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 8d5739f -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on 8d5739f {1} - โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 8d5739f +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 8d5739f {1} + โ””โ”€โ”€ ๐Ÿ“™:A โ”œโ”€โ”€ ยทffde79e (๐Ÿ˜๏ธ|โœ“) โ””โ”€โ”€ ยท86b55e6 (๐Ÿ˜๏ธ|โœ“) @@ -1000,7 +1000,7 @@ fn fully_integrated_single_branch_reparents_workspace_commit_to_advanced_target( snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 6b20716 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 6b20716 "#]] ); @@ -1074,9 +1074,9 @@ fn non_bottom_update_selector_does_not_prune_fully_integrated_stack() -> Result< snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 8d5739f -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on 8d5739f {1} - โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 8d5739f +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 8d5739f {1} + โ””โ”€โ”€ ๐Ÿ“™:A โ”œโ”€โ”€ ยทffde79e (๐Ÿ˜๏ธ|โœ“) โ””โ”€โ”€ ยท86b55e6 (๐Ÿ˜๏ธ|โœ“) @@ -1133,9 +1133,9 @@ fn fully_integrated_single_branch_reparents_workspace_commit_to_advanced_merge_t snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ2 on 8d5739f -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on 8d5739f {1} - โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ2 on 8d5739f +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 8d5739f {1} + โ””โ”€โ”€ ๐Ÿ“™:A โ”œโ”€โ”€ ยทffde79e (๐Ÿ˜๏ธ|โœ“) โ””โ”€โ”€ ยท86b55e6 (๐Ÿ˜๏ธ|โœ“) @@ -1158,7 +1158,7 @@ fn fully_integrated_single_branch_reparents_workspace_commit_to_advanced_merge_t snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on f27db86 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on f27db86 "#]] ); @@ -1463,12 +1463,12 @@ fn workspace_target_parent_updates_while_stack_parent_remains_anonymous_segment_ snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fe9ae6e -โ”œโ”€โ”€ โ‰ก:5:anon: on fe9ae6e -โ”‚ โ””โ”€โ”€ :5:anon: +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fe9ae6e +โ”œโ”€โ”€ โ‰ก:anon: on fe9ae6e +โ”‚ โ””โ”€โ”€ :anon: โ”‚ โ””โ”€โ”€ ยท0d97cc1 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:A on fe9ae6e {1} - โ””โ”€โ”€ ๐Ÿ“™:4:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on fe9ae6e {1} + โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ ยท90d25da (๐Ÿ˜๏ธ) "#]] @@ -1489,12 +1489,12 @@ fn workspace_target_parent_updates_while_stack_parent_remains_anonymous_segment_ snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fe9ae6e -โ”œโ”€โ”€ โ‰ก:5:anon: on fe9ae6e -โ”‚ โ””โ”€โ”€ :5:anon: +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fe9ae6e +โ”œโ”€โ”€ โ‰ก:anon: on fe9ae6e +โ”‚ โ””โ”€โ”€ :anon: โ”‚ โ””โ”€โ”€ ยท0d97cc1 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on 20a5ffc {1} - โ””โ”€โ”€ ๐Ÿ“™:3:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 20a5ffc {1} + โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ ยทc529875 (๐Ÿ˜๏ธ) "#]] @@ -1670,14 +1670,14 @@ fn partially_integrated_branch_leaves_multi_branch_stack() -> Result<()> { snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 3183e43 {1} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {1} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A โ”‚ โ”‚ โ””โ”€โ”€ ยท44c9428 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:5:C +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยทf1e7451 (๐Ÿ˜๏ธ|โœ“) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 3183e43 {2} - โ””โ”€โ”€ ๐Ÿ“™:4:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {2} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทb38b04b (๐Ÿ˜๏ธ) "#]] @@ -1705,12 +1705,12 @@ fn partially_integrated_branch_leaves_multi_branch_stack() -> Result<()> { snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on f1e7451 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on f1e7451 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on f1e7451 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on f1e7451 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท44c9428 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on f1e7451 {2} - โ””โ”€โ”€ ๐Ÿ“™:4:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on f1e7451 {2} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทa27415e (๐Ÿ˜๏ธ) "#]] @@ -1776,14 +1776,14 @@ fn fully_integrated_multi_branch_stack_leaves_workspace_shape() -> Result<()> { snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:5:A on 3183e43 {1} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:5:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {1} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A โ”‚ โ”‚ โ””โ”€โ”€ ยท44c9428 (๐Ÿ˜๏ธ|โœ“) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:C +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยทf1e7451 (๐Ÿ˜๏ธ|โœ“) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 3183e43 {2} - โ””โ”€โ”€ ๐Ÿ“™:4:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {2} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทb38b04b (๐Ÿ˜๏ธ) "#]] @@ -1811,9 +1811,9 @@ fn fully_integrated_multi_branch_stack_leaves_workspace_shape() -> Result<()> { snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 44c9428 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:B on 44c9428 {2} - โ””โ”€โ”€ ๐Ÿ“™:3:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 44c9428 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 44c9428 {2} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทf59d71f (๐Ÿ˜๏ธ) "#]] @@ -1882,12 +1882,12 @@ fn fully_integrated_two_stacks_leave_workspace_shape() -> Result<()> { snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ2 on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:4:A on 3183e43 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:4:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ2 on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ยท905d6e5 (๐Ÿ˜๏ธ|โœ“) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:5:B on 3183e43 {2} - โ””โ”€โ”€ ๐Ÿ“™:5:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {2} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทb38b04b (๐Ÿ˜๏ธ|โœ“) "#]] @@ -1915,7 +1915,7 @@ fn fully_integrated_two_stacks_leave_workspace_shape() -> Result<()> { snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 5f7d45e +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 5f7d45e "#]] ); @@ -2164,9 +2164,9 @@ fn empty_branch_with_integrated_remote_tip_is_removed() -> Result<()> { snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 563a7fc -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:topic <> origin/topic โ†’:5: on 563a7fc {1} - โ””โ”€โ”€ ๐Ÿ“™:4:topic <> origin/topic โ†’:5: +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 563a7fc +โ””โ”€โ”€ โ‰ก๐Ÿ“™:topic <> origin/topic on 563a7fc {1} + โ””โ”€โ”€ ๐Ÿ“™:topic <> origin/topic โ””โ”€โ”€ โ„๏ธ6ba217e (๐Ÿ˜๏ธ|โœ“) "#]] @@ -2199,9 +2199,9 @@ fn empty_branch_with_integrated_remote_tip_is_removed() -> Result<()> { snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 563a7fc -โ””โ”€โ”€ โ‰ก:2:main <> origin/main โ†’:1: on 563a7fc - โ””โ”€โ”€ :2:main <> origin/main โ†’:1: +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 563a7fc +โ””โ”€โ”€ โ‰ก:main <> origin/main on 563a7fc + โ””โ”€โ”€ :main <> origin/main โ””โ”€โ”€ โ„๏ธ364a08f (๐Ÿ˜๏ธ|โœ“) "#]] @@ -2267,9 +2267,9 @@ fn non_empty_branch_with_integrated_remote_tip_keeps_local_work() -> Result<()> snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 563a7fc -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:topic <> origin/topic โ†’:5:โ‡ก1 on 563a7fc {1} - โ””โ”€โ”€ ๐Ÿ“™:4:topic <> origin/topic โ†’:5:โ‡ก1 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 563a7fc +โ””โ”€โ”€ โ‰ก๐Ÿ“™:topic <> origin/topicโ‡ก1 on 563a7fc {1} + โ””โ”€โ”€ ๐Ÿ“™:topic <> origin/topicโ‡ก1 โ”œโ”€โ”€ ยทf1a3cba (๐Ÿ˜๏ธ) โ””โ”€โ”€ โ„๏ธ6ba217e (๐Ÿ˜๏ธ|โœ“) @@ -2303,11 +2303,11 @@ fn non_empty_branch_with_integrated_remote_tip_keeps_local_work() -> Result<()> snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 563a7fc -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:topic <> origin/topic โ†’:5:โ‡ก1 on 563a7fc {1} - โ”œโ”€โ”€ ๐Ÿ“™:4:topic <> origin/topic โ†’:5:โ‡ก1 +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 563a7fc +โ””โ”€โ”€ โ‰ก๐Ÿ“™:topic <> origin/topicโ‡ก1 on 563a7fc {1} + โ”œโ”€โ”€ ๐Ÿ“™:topic <> origin/topicโ‡ก1 โ”‚ โ””โ”€โ”€ ยทf3ceb3d (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :2:main <> origin/main โ†’:1: + โ””โ”€โ”€ :main <> origin/main โ””โ”€โ”€ โ„๏ธ364a08f (๐Ÿ˜๏ธ|โœ“) "#]] @@ -2372,10 +2372,10 @@ fn empty_branch_above_integrated_branch_is_preserved() -> Result<()> { snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 563a7fc -โ””โ”€โ”€ โ‰ก๐Ÿ“™:7:top <> origin/top โ†’:6: on 563a7fc {1} - โ”œโ”€โ”€ ๐Ÿ“™:7:top <> origin/top โ†’:6: - โ””โ”€โ”€ ๐Ÿ“™:8:bottom <> origin/bottom โ†’:5: +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 563a7fc +โ””โ”€โ”€ โ‰ก๐Ÿ“™:top <> origin/top on 563a7fc {1} + โ”œโ”€โ”€ ๐Ÿ“™:top <> origin/top + โ””โ”€โ”€ ๐Ÿ“™:bottom <> origin/bottom โ””โ”€โ”€ โ„๏ธ141de4f (๐Ÿ˜๏ธ|โœ“) "#]] @@ -2416,9 +2416,9 @@ fn empty_branch_above_integrated_branch_is_preserved() -> Result<()> { snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 563a7fc -โ””โ”€โ”€ โ‰ก๐Ÿ“™:2:top <> origin/top โ†’:4: on 563a7fc {1} - โ””โ”€โ”€ ๐Ÿ“™:2:top <> origin/top โ†’:4: +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 563a7fc +โ””โ”€โ”€ โ‰ก๐Ÿ“™:top <> origin/top on 563a7fc {1} + โ””โ”€โ”€ ๐Ÿ“™:top <> origin/top โ””โ”€โ”€ ยท334227d (๐Ÿ˜๏ธ|โœ“) "#]] @@ -2646,13 +2646,13 @@ fn review_hint_integrates_squashed_two_commit_stack_in_managed_workspace() -> Re snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:3:A on 3183e43 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A โ”‚ โ”œโ”€โ”€ ยทad1d22b (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยทfe98e29 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:4:B on 3183e43 {2} - โ””โ”€โ”€ ๐Ÿ“™:4:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {2} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทb38b04b (๐Ÿ˜๏ธ) "#]] @@ -2680,9 +2680,9 @@ fn review_hint_integrates_squashed_two_commit_stack_in_managed_workspace() -> Re snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:B on 3183e43 {2} - โ””โ”€โ”€ ๐Ÿ“™:3:B +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {2} + โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทb38b04b (๐Ÿ˜๏ธ) "#]] @@ -2761,9 +2761,9 @@ fn review_hint_integrates_squashed_two_commit_direct_checkout_branch() -> Result snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -โŒ‚:1:A[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 -โ””โ”€โ”€ โ‰ก:1:A[๐ŸŒณ] on 3183e43 {1} - โ””โ”€โ”€ :1:A[๐ŸŒณ] +โŒ‚:A[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 +โ””โ”€โ”€ โ‰ก:A[๐ŸŒณ] on 3183e43 {1} + โ””โ”€โ”€ :A[๐ŸŒณ] โ”œโ”€โ”€ ยทad1d22b โ””โ”€โ”€ ยทfe98e29 @@ -2791,9 +2791,9 @@ fn review_hint_integrates_squashed_two_commit_direct_checkout_branch() -> Result snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -โŒ‚:0:amo-branch-1[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 -โ””โ”€โ”€ โ‰ก:0:amo-branch-1[๐ŸŒณ] on 3183e43 {1} - โ””โ”€โ”€ :0:amo-branch-1[๐ŸŒณ] +โŒ‚:amo-branch-1[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 +โ””โ”€โ”€ โ‰ก:amo-branch-1[๐ŸŒณ] on 3183e43 {1} + โ””โ”€โ”€ :amo-branch-1[๐ŸŒณ] โ””โ”€โ”€ ยท56057f2 "#]] @@ -2864,9 +2864,9 @@ fn review_hint_integrates_squashed_prefix_and_keeps_extra_commit_in_managed_work snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on 3183e43 {1} - โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {1} + โ””โ”€โ”€ ๐Ÿ“™:A โ”œโ”€โ”€ ยทf015e95 (๐Ÿ˜๏ธ) โ”œโ”€โ”€ ยทad1d22b (๐Ÿ˜๏ธ) โ””โ”€โ”€ ยทfe98e29 (๐Ÿ˜๏ธ) @@ -2893,9 +2893,9 @@ fn review_hint_integrates_squashed_prefix_and_keeps_extra_commit_in_managed_work snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e2f5892 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:3:A on e2f5892 {1} - โ””โ”€โ”€ ๐Ÿ“™:3:A +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e2f5892 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on e2f5892 {1} + โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ ยท92f1780 (๐Ÿ˜๏ธ) "#]] @@ -2963,9 +2963,9 @@ fn review_hint_integrates_squashed_prefix_and_keeps_extra_commit_in_direct_check snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -โŒ‚:1:A[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 -โ””โ”€โ”€ โ‰ก:1:A[๐ŸŒณ] on 3183e43 {1} - โ””โ”€โ”€ :1:A[๐ŸŒณ] +โŒ‚:A[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 +โ””โ”€โ”€ โ‰ก:A[๐ŸŒณ] on 3183e43 {1} + โ””โ”€โ”€ :A[๐ŸŒณ] โ”œโ”€โ”€ ยทf015e95 โ”œโ”€โ”€ ยทad1d22b โ””โ”€โ”€ ยทfe98e29 @@ -3010,9 +3010,9 @@ fn review_hint_integrates_squashed_prefix_and_keeps_extra_commit_in_direct_check snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" -โŒ‚:1:A[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e2f5892 -โ””โ”€โ”€ โ‰ก:1:A[๐ŸŒณ] on e2f5892 {1} - โ””โ”€โ”€ :1:A[๐ŸŒณ] +โŒ‚:A[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e2f5892 +โ””โ”€โ”€ โ‰ก:A[๐ŸŒณ] on e2f5892 {1} + โ””โ”€โ”€ :A[๐ŸŒณ] โ””โ”€โ”€ ยท92f1780 "#]] From f28a7952a1169460d64832a3be5582aa52dfb62f Mon Sep 17 00:00:00 2001 From: Mattias Granlund Date: Sun, 12 Jul 2026 11:30:04 +0300 Subject: [PATCH 02/11] Introduce the CommitGraph: commits, parents, and refs as data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First piece of an arc that replaces the segment graph with one substrate shared by display and rebase. The CommitGraph is what the name says โ€” the traversal kept as plain data: repository โ”€โ”€walkโ”€โ”€โ–ถ CommitGraph { commits, ordered parent_ids, refs } - parent arrays keep their order, and the workspace commit's parent array IS the stack order โ€” no order-stacks pass downstream, no tied-order bugs - every ref the walk meets attaches to its commit as data; refs are never nodes (a ref node creates reachability the commit topology doesn't have, which is the common root of a family of bugs โ€” see the PR body) - the walker propagates flags and obeys goals/limits like the old traversal; records.rs carries the shared Commit/RefInfo records Nothing is wired up yet โ€” no module declaration, so none of this compiles into the crate until the switch-over. Reviewing this is reading a new subsystem, not hunting for behavior changes. --- crates/but-graph/src/commit_graph.rs | 1025 +++++++++++++ .../but-graph/src/commit_graph/merge_base.rs | 234 +++ .../but-graph/src/commit_graph_diagnostics.rs | 183 +++ crates/but-graph/src/records.rs | 238 +++ crates/but-graph/src/walk/mod.rs | 1347 +++++++++++++++++ crates/but-graph/src/walk/overlay.rs | 874 +++++++++++ crates/but-graph/src/walk/remotes.rs | 72 + crates/but-graph/src/walk/types.rs | 369 +++++ crates/but-graph/src/walk/utils/mod.rs | 361 +++++ crates/but-graph/src/walk/utils/tests.rs | 119 ++ crates/but-graph/src/walk/walker.rs | 1115 ++++++++++++++ 11 files changed, 5937 insertions(+) create mode 100644 crates/but-graph/src/commit_graph.rs create mode 100644 crates/but-graph/src/commit_graph/merge_base.rs create mode 100644 crates/but-graph/src/commit_graph_diagnostics.rs create mode 100644 crates/but-graph/src/records.rs create mode 100644 crates/but-graph/src/walk/mod.rs create mode 100644 crates/but-graph/src/walk/overlay.rs create mode 100644 crates/but-graph/src/walk/remotes.rs create mode 100644 crates/but-graph/src/walk/types.rs create mode 100644 crates/but-graph/src/walk/utils/mod.rs create mode 100644 crates/but-graph/src/walk/utils/tests.rs create mode 100644 crates/but-graph/src/walk/walker.rs diff --git a/crates/but-graph/src/commit_graph.rs b/crates/but-graph/src/commit_graph.rs new file mode 100644 index 00000000000..9036063aa57 --- /dev/null +++ b/crates/but-graph/src/commit_graph.rs @@ -0,0 +1,1025 @@ +//! The commit graph: the raw traversal flattened into nodes โ€” the single arena the +//! build authors its ref layout onto and every consumer reads (see `build`). +//! +//! Pipeline: `gix traversal โ†’ CommitGraph (+ ref layout) โ†’ projection`. The traversal +//! accumulates the arena directly ([`CommitGraph::from_walk`]), the build stores the ref +//! layout on it, the projection derives the [`Workspace`](crate::Workspace) from it, and +//! but-rebase's editor adopts the carried copy as its mutable arena. Both consumers are +//! commit/ref-granular: the build's segments never leave the builder. +//! +//! A node IS a commit ([`crate::Commit`]); every per-node attribute โ€” parent edges, children, +//! tombstones, the topological `generation` โ€” rides a parallel array. An edge is +//! `commit โ†’ parent` from `parent_ids` (first-parent at index 0). A nice consequence: the +//! workspace commit's `parent_ids` array IS the stack order, so no order-stacks post-pass. + +use gix::hashtable::{HashMap, HashSet}; + +use crate::{Commit, CommitFlags}; + +mod merge_base; + +/// One `commit โ†’ parent` edge, HANDLE-based: parallel to one entry of the commit's `parent_ids`. +/// The raw id in `parent_ids` is payload; this is the graph structure. +#[derive(Debug, Clone, Copy)] +struct ParentEdge { + /// The parent's node, when it is present in the arena. `None` = the raw parent id points + /// outside the arena (partial traversal โ€” this subgraph roots here). + target_node_idx: Option, + /// Whether the traversal actually FOLLOWED this link. A parent can be present in the arena + /// via another path while this specific edge was severed (limits, integrated stop-early). + connected: bool, +} + +/// The commit graph: an arena of commit nodes with HANDLE-based `commit โ†’ parent` edges (one +/// `ParentEdge` per raw `parent_ids` entry) and the reverse (`parent โ†’ child`) adjacency derived +/// for downward walks. `ObjectId` is pure payload; `by_id` is a rebuildable lookup index. +#[derive(Debug, Clone, Default)] +pub struct CommitGraph { + nodes: Vec, + /// Per node: distance from a root; higher means deeper in history. Used where the + /// projection picks "the lowest of several tips". Creation-time data + /// (see `compute_generations`), not maintained by the editor mutation surface. + generations: Vec, + by_id: HashMap, + /// Ref name โ†’ node holding it. Like `by_id` a rebuildable lookup: rebuilt whenever refs + /// are written (construction, [`Self::refresh_refs`]) or indices move ([`Self::compact`]). + by_ref: std::collections::HashMap, + /// Per node, one edge per `parent_ids` entry โ€” presence and connectivity, in parent-number order. + parent_edges: Vec>, + /// Rows the EDITOR removed (see the mutation surface). A tombstoned node keeps its arena + /// index and (stale) payload; id-based reads skip it. Walk-built graphs have none. + tombstoned: Vec, + /// `parent โ†’ children` adjacency, derived at build time for branch-point detection and + /// downward walks (the projection walks tip toward base). + children: Vec>, + /// Where traversal/HEAD started; the projection uses it as a focus boundary. + entrypoint: Option, + /// The ref the entrypoint was checked out as, if any. When set, it names the entrypoint segment + /// (overriding disambiguation), mirroring `from_tip(id, Some(ref))`. + entrypoint_ref: Option, + /// Commits whose message marks them as a GitButler-managed workspace commit. Kept out of + /// [`CommitFlags`](crate::CommitFlags) so it neither perturbs the walk's goal bits nor the + /// segment fingerprint; used to tell a real managed merge from a ws ref advanced past it. + managed_ws_commits: HashSet, + /// When built [from the walk](Self::from_walk): whether the traversal stopped queueing after + /// hitting the hard limit. Derived graphs must carry it forward. + pub(crate) hard_limit_hit: bool, + /// When built [from the walk](Self::from_walk): the traversal's normalized seed tips. Graphs + /// built from EXPLICIT tips must carry them forward โ€” the projection reads tip roles + /// (e.g. integrated tips) for such graphs. + pub(crate) seeds: Vec, + /// Built from EXPLICIT tips ([`Self::from_walk_seeds`]): every tip must start (or get) its own + /// segment. Workspace-discovered builds must NOT carve boundaries at their normalized tips โ€” + /// there they are ordinary interior commits unless the plan makes them boundaries. + pub(crate) explicit_seeds: bool, + /// When built [from the walk](Self::from_walk): the extra target commit the traversal was + /// seeded with (`Options::extra_target_commit_id`). Carried like [`Self::hard_limit_hit`] so + /// the projection can surface it as a seed without consulting the walk options. + pub(crate) extra_target: Option, + /// The ref placement table authored at build time (see [`ref_layout`](crate::ref_layout)); + /// `None` until the commit graph goes through the builder. Mirrors build-time refs โ€” like + /// [`Commit::refs`](crate::Commit) it goes stale under editor mutation and is re-authored + /// by the write-through projection. + pub(crate) layout: Option, +} + +impl CommitGraph { + fn parent_edges_of(&self, id: gix::ObjectId) -> Option<&[ParentEdge]> { + self.by_id + .get(&id) + .map(|&idx| self.parent_edges[idx].as_slice()) + } + + /// The id โ†’ node lookup for `nodes` (payloads are unique). + fn index_by_id(nodes: &[Commit]) -> HashMap { + nodes + .iter() + .enumerate() + .map(|(idx, n)| (n.id, idx)) + .collect() + } + + /// The parent โ†’ children adjacency derived from `parent_edges`. `connected_only` + /// mirrors the walk (a severed edge yields no child); [`Self::compact`] keeps every + /// PRESENT target โ€” see the note there. + fn derive_children(parent_edges: &[Vec], connected_only: bool) -> Vec> { + let mut children = vec![Vec::new(); parent_edges.len()]; + for (idx, edges) in parent_edges.iter().enumerate() { + for edge in edges { + if let Some(pidx) = edge.target_node_idx + && (edge.connected || !connected_only) + { + children[pidx].push(idx); + } + } + } + children + } + + /// Assemble an arena from its authoritative parts โ€” children, generations, and the ref + /// lookup are derived; the walk-carried fields stay at their defaults for the caller. + fn from_parts( + nodes: Vec, + by_id: HashMap, + parent_edges: Vec>, + ) -> Self { + let mut table = CommitGraph { + generations: vec![0; nodes.len()], + children: Self::derive_children(&parent_edges, true), + tombstoned: vec![false; nodes.len()], + nodes, + by_id, + parent_edges, + ..Default::default() + }; + table.recompute_generations(); + table.rebuild_by_ref(); + table + } + + /// Assemble from the walk's outcome (see `walk::walker`): the walk's `by_id` is adopted + /// as-is (collection order IS node order), and parent-edge connectivity comes straight from the + /// followed edges rather than being re-derived. + #[tracing::instrument(name = "CommitGraph::from_walk_outcome", level = "trace", skip_all)] + pub(crate) fn from_walk_outcome(o: crate::walk::walker::WalkOutcome) -> Self { + let nodes: Vec = o.commits; + let by_id = o.by_id; + let parent_edges: Vec> = nodes + .iter() + .map(|n| { + let followed = o.parents_followed.get(&n.id); + n.parent_ids + .iter() + .map(|parent| ParentEdge { + target_node_idx: by_id.get(parent).copied(), + connected: followed.is_some_and(|ps| ps.contains(parent)), + }) + .collect() + }) + .collect(); + let mut table = Self::from_parts(nodes, by_id, parent_edges); + table.entrypoint = o.entrypoint; + table.entrypoint_ref = o.entrypoint_ref; + table.hard_limit_hit = o.hard_limit_hit; + table.seeds = o.seeds; + // Goal bits stop mattering once the traversal ends, and their numbering depends on tip + // processing order โ€” carrying them would break cross-build comparison. + table.strip_goal_flags(); + table + } + + /// Build by running the real traversal, accumulating commits directly โ€” extents (limit cuts, + /// integrated stop-early) and flags keep the walk's battle-tested semantics. + pub(crate) fn from_walk( + overlay_repo: &crate::walk::overlay::OverlayRepo<'_>, + overlay_meta: &crate::walk::overlay::OverlayMetadata<'_, T>, + tip: gix::ObjectId, + ref_name: Option, + project_meta: but_core::ref_metadata::ProjectMeta, + options: crate::walk::Options, + ) -> anyhow::Result { + let extra_target = options.extra_target_commit_id; + let seeds = crate::walk::initial_seeds_from_workspace_metadata( + overlay_repo, + overlay_meta, + tip, + ref_name.as_ref(), + &project_meta, + extra_target, + )?; + let walked = crate::walk::walker::traverse( + overlay_repo, + seeds, + overlay_meta, + project_meta, + options, + ref_name, + )?; + let mut outcome = Self::from_walk_outcome(walked); + outcome.extra_target = extra_target; + Ok(outcome) + } + + /// Like [`Self::from_walk`], but seeded from explicit `tips`. + pub(crate) fn from_walk_seeds( + overlay_repo: &crate::walk::overlay::OverlayRepo<'_>, + overlay_meta: &crate::walk::overlay::OverlayMetadata<'_, T>, + tips: Vec, + project_meta: but_core::ref_metadata::ProjectMeta, + options: crate::walk::Options, + ) -> anyhow::Result { + let extra_target = options.extra_target_commit_id; + let walked = crate::walk::walker::traverse( + overlay_repo, + tips, + overlay_meta, + project_meta, + options, + None, + )?; + let mut outcome = Self::from_walk_outcome(walked); + outcome.explicit_seeds = true; + outcome.extra_target = extra_target; + Ok(outcome) + } + + /// Mark `id` as a GitButler-managed workspace commit when its message says so. + pub(crate) fn mark_managed_ws_commit_by_message( + &mut self, + repo: &gix::Repository, + id: gix::ObjectId, + ) { + if let Ok(commit) = repo.find_commit(id) + && let Ok(message) = commit.message_raw() + && crate::workspace::commit::is_managed_workspace_by_message(message) + { + self.managed_ws_commits.insert(id); + } + } + + /// Where traversal/HEAD started (a checkout inside a stack), if any. The projection forces a + /// segment boundary here โ€” there is always a segment starting at the entrypoint. + pub fn entrypoint(&self) -> Option { + self.entrypoint + } + + /// The ref the entrypoint was checked out as, if any โ€” it names the entrypoint segment. + pub fn entrypoint_ref(&self) -> Option<&gix::refs::FullName> { + self.entrypoint_ref.as_ref() + } + /// The worktrees referenced by any commit's refs. + pub(crate) fn ref_worktrees(&self) -> impl Iterator { + self.nodes + .iter() + .flat_map(|n| n.refs.iter()) + .filter_map(|ri| ri.worktree.as_ref()) + } + + /// The ref placement table authored at build time, when this graph went through the builder. + pub fn layout(&self) -> Option<&crate::ref_layout::RefLayout> { + self.layout.as_ref() + } + + /// Whether `id` is a GitButler-managed workspace commit (recognised by its message). + pub(crate) fn is_managed_ws_commit(&self, id: gix::ObjectId) -> bool { + self.managed_ws_commits.contains(&id) + } + + /// Replace every node's attached refs with the CURRENT `refs_by_id` state โ€” the + /// write-through seam's enrichment refresh: an editor-mutated graph still carries + /// walk-time refs, but materialization has moved them. Matched entries are consumed. + pub(crate) fn refresh_refs( + &mut self, + refs_by_id: &mut crate::walk::utils::RefsById, + worktree_by_branch: &crate::walk::utils::WorktreeByBranch, + ) { + for (idx, node) in self.nodes.iter_mut().enumerate() { + if self.tombstoned[idx] { + node.refs.clear(); + continue; + } + let id = node.id; + let mut refs: Vec = refs_by_id + .remove(&id) + .unwrap_or_default() + .into_iter() + .map(|rn| crate::RefInfo::from_ref(rn, id, worktree_by_branch)) + .collect(); + refs.sort_by(|a, b| a.ref_name.cmp(&b.ref_name)); + node.refs = refs; + } + self.rebuild_by_ref(); + } + + /// Mutable access to the refs attached to `id`, for anonymization. Callers must + /// rebuild the ref lookup afterwards. + pub(crate) fn commit_refs_mut( + &mut self, + id: gix::ObjectId, + ) -> Option<&mut Vec> { + let idx = self.index_of(id)?; + Some(&mut self.nodes[idx].refs) + } + + /// Mutable access to the entrypoint ref, for anonymization. + pub(crate) fn entrypoint_ref_mut(&mut self) -> Option<&mut gix::refs::FullName> { + self.entrypoint_ref.as_mut() + } + + /// Set the ref the entrypoint was checked out as โ€” for graphs assembled + /// without a walk (unborn refs). + pub(crate) fn set_entrypoint_ref(&mut self, name: gix::refs::FullName) { + self.entrypoint_ref = Some(name); + } + + /// Rebuild the ref-name lookup from the (live) nodes' attached refs. + pub(crate) fn rebuild_by_ref(&mut self) { + self.by_ref.clear(); + for (idx, node) in self.nodes.iter().enumerate() { + if self.tombstoned[idx] { + continue; + } + for r in &node.refs { + self.by_ref.insert(r.ref_name.clone(), idx); + } + } + } + + /// Overwrite the node's flags โ€” the write-through seam flags re-added tip regions + /// Integrated, the walk's convention for target-seeded tips. + pub(crate) fn set_flags(&mut self, idx: usize, flags: crate::CommitFlags) { + self.nodes[idx].flags = flags; + } + + /// Set `flag` on exactly the (live) ancestors of `tips`, clearing it elsewhere โ€” the + /// write-through seam's flag refresh: an editor-mutated graph carries walk-time flags + /// (empty on editor-added nodes), while the rewalk derives each flag fresh. Which tips + /// seed which flag is the walk's rule, spelled at the call sites. + pub(crate) fn set_flag_on_ancestors( + &mut self, + flag: crate::CommitFlags, + tips: impl IntoIterator, + ) { + let mut marked: HashSet = HashSet::default(); + let mut queue: Vec = tips + .into_iter() + .filter(|tip| self.by_id.contains_key(tip)) + .collect(); + while let Some(id) = queue.pop() { + if marked.insert(id) { + queue.extend(self.all_parent_ids(id)); + } + } + for (idx, node) in self.nodes.iter_mut().enumerate() { + if self.tombstoned[idx] { + continue; + } + node.flags.set(flag, marked.contains(&node.id)); + } + } + + /// Bring a TOMBSTONED node holding `id` back to life โ€” a target the editor dropped is still + /// external context on disk, and the walk always seeds it. Returns `true` on actual revival: + /// that flips the effective parents of children substituting through this node, so callers + /// may need to re-validate them. + pub(crate) fn revive(&mut self, id: gix::ObjectId) -> bool { + if self.by_id.contains_key(&id) { + return false; + } + let Some(idx) = + (0..self.nodes.len()).find(|&i| self.tombstoned[i] && self.nodes[i].id == id) + else { + return false; + }; + self.tombstoned[idx] = false; + self.by_id.insert(id, idx); + true + } + + /// The commit holding `id`, if present. + pub fn node(&self, id: gix::ObjectId) -> Option<&Commit> { + self.by_id.get(&id).map(|&idx| &self.nodes[idx]) + } + + /// The topological generation of the commit with `id`, if present โ€” distance from a + /// root, higher is deeper in history. Creation-time data: the editor's mutation + /// surface does not maintain it. + pub fn generation_of(&self, id: gix::ObjectId) -> Option { + self.by_id.get(&id).map(|&idx| self.generations[idx]) + } + + /// The topological generation of the node at `idx`. + pub(crate) fn generation_at(&self, idx: usize) -> u32 { + self.generations[idx] + } + + /// The commit holding `name` โ€” the ref's target in the arena. + pub(crate) fn ref_target(&self, name: &gix::refs::FullName) -> Option { + self.by_ref.get(name).map(|&idx| self.nodes[idx].id) + } + + /// The node index of `id`, if present. + pub fn index_of(&self, id: gix::ObjectId) -> Option { + self.by_id.get(&id).copied() + } + + /// Every commit id in the arena, in node order. + pub fn commit_ids(&self) -> impl Iterator + '_ { + self.nodes.iter().map(|n| n.id) + } + + /// The commit's CONNECTED parents, first-parent first โ€” parents the traversal severed + /// (limits, integrated stop-early, display cuts) are omitted. A TOMBSTONED parent is + /// substituted by its own parents, recursively (the editor's descent), deduplicated along + /// the way; plain duplicate parents are all kept (dup-parent workspace commits). + pub(crate) fn all_parent_ids(&self, id: gix::ObjectId) -> Vec { + let Some(&idx) = self.by_id.get(&id) else { + return Vec::new(); + }; + // Fast path: no tombstoned parent to substitute through (always true for walk-built + // graphs), so the connected edges map straight to parent ids. + if !self.has_tombstoned_parent(idx) { + return self.nodes[idx] + .parent_ids + .iter() + .copied() + .zip(&self.parent_edges[idx]) + .filter(|(_, edge)| edge.connected) + .map(|(p, edge)| match edge.target_node_idx { + Some(t) => self.nodes[t].id, + None => p, + }) + .collect(); + } + // The CONNECTED `(raw parent id, edge target)` pairs of `idx`, in parent-number order. + let connected = |idx: usize| { + self.nodes[idx] + .parent_ids + .iter() + .copied() + .zip(&self.parent_edges[idx]) + .filter_map(|(p, edge)| edge.connected.then_some((p, edge.target_node_idx))) + .collect::>() + }; + let mut potential: Vec<(gix::ObjectId, Option)> = + connected(idx).into_iter().rev().collect(); + let mut seen_idx: std::collections::HashSet = + potential.iter().filter_map(|(_, t)| *t).collect(); + let mut seen_raw: HashSet = potential + .iter() + .filter_map(|(p, t)| t.is_none().then_some(*p)) + .collect(); + let mut parents = Vec::new(); + while let Some((raw, target)) = potential.pop() { + match target { + Some(t) if self.tombstoned[t] => { + for (p_raw, p_target) in connected(t).into_iter().rev() { + let unseen = match p_target { + Some(pt) => seen_idx.insert(pt), + None => seen_raw.insert(p_raw), + }; + if unseen { + potential.push((p_raw, p_target)); + } + } + } + // A live target's CURRENT payload id is authoritative (raw agrees via + // set_commit_id's child patching; this needs no such guarantee). + Some(t) => parents.push(self.nodes[t].id), + None => parents.push(raw), + } + } + parents + } + + /// The RAW recorded parent ids of `idx` โ€” the payload array, cut edges included, no + /// tombstone substitution (unlike [`Self::all_parent_ids`]). + pub(crate) fn raw_parent_ids(&self, idx: usize) -> &[gix::ObjectId] { + &self.nodes[idx].parent_ids + } + + /// `true` if any CONNECTED parent edge of `idx` targets a tombstone โ€” traversal would + /// substitute through it, so the raw recorded parents disagree with what a walk sees. + pub(crate) fn has_tombstoned_parent(&self, idx: usize) -> bool { + self.parent_edges[idx] + .iter() + .any(|edge| edge.connected && edge.target_node_idx.is_some_and(|t| self.tombstoned[t])) + } + + /// All ancestors of `tip` (inclusive), following CONNECTED parent edges โ€” history the + /// traversal severed is not rejoined. Bounded by the arena, which is the traversal-limited + /// window, not the repository. + pub fn ancestor_set(&self, tip: gix::ObjectId) -> HashSet { + let mut set = HashSet::default(); + let mut queue = std::collections::VecDeque::from([tip]); + while let Some(c) = queue.pop_front() { + if set.insert(c) { + queue.extend(self.all_parent_ids(c)); + } + } + set + } + + /// The first commit along `tip`'s first-parent spine for which `is_in_set` holds โ€” where an + /// outside line (a remote, the target, an outside checkout, a branch that advanced past the + /// workspace) rejoins a marked region of the graph. + pub(crate) fn first_on_spine( + &self, + tip: gix::ObjectId, + is_in_set: impl Fn(usize) -> bool, + ) -> Option { + let mut cursor = self.index_of(tip); + while let Some(c) = cursor { + if is_in_set(c) { + return Some(self.id_at(c)); + } + cursor = self.first_parent_at(c); + } + None + } + + /// [`Self::ancestor_set`] in handle space: `marks[idx]` for every reachable LIVE node. The + /// walk passes THROUGH tombstones without marking them, matching the id-space substitution. + /// Query membership via [`Self::index_of`]. + pub fn ancestor_marks(&self, tip: gix::ObjectId) -> Vec { + let mut marks = vec![false; self.nodes.len()]; + let mut queue: Vec = self.index_of(tip).into_iter().collect(); + while let Some(c) = queue.pop() { + if std::mem::replace(&mut marks[c], true) { + continue; + } + for edge in &self.parent_edges[c] { + if edge.connected + && let Some(p) = edge.target_node_idx + { + queue.push(p); + } + } + } + for (i, dead) in self.tombstoned.iter().enumerate() { + if *dead { + marks[i] = false; + } + } + marks + } + + /// `true` if any of `id`'s recorded parents is not CONNECTED โ€” the traversal cut history + /// here (limits, integrated stop-early), so ancestry continues beyond the arena. + pub fn has_cut_parents(&self, id: gix::ObjectId) -> bool { + self.parent_edges_of(id) + .is_some_and(|edges| edges.iter().any(|edge| !edge.connected)) + } + + /// [`Self::has_cut_parents`] by handle. + pub fn has_cut_parents_at(&self, idx: usize) -> bool { + self.parent_edges[idx].iter().any(|edge| !edge.connected) + } + + /// The commit that `ref_name` points at, if present in the arena. + pub fn commit_by_ref(&self, ref_name: &gix::refs::FullNameRef) -> Option { + self.by_ref + .get(ref_name) + .filter(|&&idx| !self.tombstoned[idx]) + .map(|&idx| self.nodes[idx].id) + } + + /// The reference names pointing at `id`. + pub(crate) fn refs_at(&self, id: gix::ObjectId) -> Vec { + self.node(id) + .map(|n| n.refs.iter().map(|r| r.ref_name.clone()).collect()) + .unwrap_or_default() + } + + /// The parents of `id` that are present in the arena, first-parent first. + pub fn parents(&self, id: gix::ObjectId) -> impl Iterator + '_ { + self.parent_edges_of(id) + .unwrap_or_default() + .iter() + .filter_map(|edge| edge.target_node_idx.map(|idx| self.nodes[idx].id)) + } + + /// The parents of `id` the traversal actually followed, first-parent first โ€” present + /// parents minus severed edges (limits, integrated stop-early). + pub fn connected_parents(&self, id: gix::ObjectId) -> impl Iterator + '_ { + self.parent_edges_of(id) + .unwrap_or_default() + .iter() + .filter(|edge| edge.connected) + .filter_map(|edge| edge.target_node_idx.map(|idx| self.nodes[idx].id)) + } + + /// The first parent of `id` (the next commit walking down first-parent), if present. + pub fn first_parent(&self, id: gix::ObjectId) -> Option { + let edge = self.parent_edges_of(id)?.first()?; + let target = edge.target_node_idx.filter(|_| edge.connected)?; + Some(self.nodes[target].id) + } + + // --- HANDLE-based reads: the builder's hot loops speak node indices (no `by_id` hashing, no + // per-call allocation). No tombstone substitution โ€” the builder only sees walk-built or + // compacted graphs (the write-through seam compacts before building). + + /// The id of the (live) node at `idx`. + pub(crate) fn id_at(&self, idx: usize) -> gix::ObjectId { + self.nodes[idx].id + } + + /// The commit at `idx`. + pub(crate) fn node_at(&self, idx: usize) -> &Commit { + &self.nodes[idx] + } + + /// The CONNECTED, PRESENT parents of `idx` in parent number order โ€” the handle-space read behind + /// [`Self::all_parent_ids`]'s fast path. + pub(crate) fn connected_parents_at(&self, idx: usize) -> impl Iterator + '_ { + debug_assert!( + !self.has_tombstoned_parent(idx), + "no substitution by handle" + ); + self.parent_edges[idx] + .iter() + .filter(|edge| edge.connected) + .filter_map(|edge| edge.target_node_idx) + } + + /// `all_parent_ids(id_at(idx)).len()` without materializing the list โ€” connected parent numbers, + /// absent targets included. + pub(crate) fn connected_parent_count_at(&self, idx: usize) -> usize { + debug_assert!( + !self.has_tombstoned_parent(idx), + "no substitution by handle" + ); + self.parent_edges[idx] + .iter() + .filter(|edge| edge.connected) + .count() + } + + /// [`Self::first_parent`] by handle. + pub(crate) fn first_parent_at(&self, idx: usize) -> Option { + let edge = self.parent_edges[idx].first()?; + edge.target_node_idx.filter(|_| edge.connected) + } + + /// [`Self::children`] by handle. + pub(crate) fn children_at(&self, idx: usize) -> &[usize] { + &self.children[idx] + } + + /// `marks[idx]`: whether `target` is an ancestor of the node (itself included), following + /// CONNECTED parent edges โ€” one linear pass instead of one graph walk per query. + pub(crate) fn reaches_marks(&self, target: gix::ObjectId) -> Vec { + let mut marks = vec![false; self.nodes.len()]; + let Some(t) = self.index_of(target) else { + return marks; + }; + marks[t] = true; + for idx in self.toposort_parents_first() { + if !marks[idx] { + marks[idx] = self.connected_parents_at(idx).any(|p| marks[p]); + } + } + marks + } + + /// Recompute `generation` for every node (longest path from a root, by Kahn order). Cheap; the + /// arena is small. + pub(crate) fn recompute_generations(&mut self) { + // Parents-first order so a child's generation is max(parent generations) + 1. + let order = self.toposort_parents_first(); + for idx in order { + let generation = self.parent_edges[idx] + .iter() + .filter_map(|edge| edge.target_node_idx) + .map(|pidx| self.generations[pidx] + 1) + .max() + .unwrap_or(0); + self.generations[idx] = generation; + } + } + + /// Topological order with parents before children (history order), over PRESENT parent numbers โ€” + /// connectivity ignored, like the generation formula. Propagating via the connected-only + /// `children` adjacency would strand nodes behind severed edges. + fn toposort_parents_first(&self) -> Vec { + let mut children = vec![Vec::new(); self.nodes.len()]; + let mut indegree = vec![0usize; self.nodes.len()]; + for (idx, edges) in self.parent_edges.iter().enumerate() { + for edge in edges { + if let Some(pidx) = edge.target_node_idx { + children[pidx].push(idx); + indegree[idx] += 1; + } + } + } + let mut queue: std::collections::VecDeque = (0..self.nodes.len()) + .filter(|&i| indegree[i] == 0) + .collect(); + let mut out = Vec::with_capacity(self.nodes.len()); + while let Some(idx) = queue.pop_front() { + out.push(idx); + for &child in &children[idx] { + indegree[child] -= 1; + if indegree[child] == 0 { + queue.push_back(child); + } + } + } + out + } + + // --- The EDITOR MUTATION SURFACE --- + // + // but-rebase's editor mutates a CommitGraph in place: node indices are the stable + // ids, `ObjectId` is payload. These writes maintain `by_id`, the children adjacency, and + // the raw `parent_ids` payload. `generation` is creation-time data and NOT maintained. + + /// Append a fresh node with `id` as payload (`None` = born tombstoned) and no parents. + pub fn add_node(&mut self, id: Option) -> usize { + let idx = self.nodes.len(); + self.nodes.push(Commit { + id: id.unwrap_or_else(|| gix::ObjectId::null(gix::hash::Kind::Sha1)), + parent_ids: Vec::new(), + flags: CommitFlags::empty(), + refs: Vec::new(), + }); + self.generations.push(0); + self.parent_edges.push(Vec::new()); + self.children.push(Vec::new()); + self.tombstoned.push(id.is_none()); + if let Some(id) = id { + self.by_id.insert(id, idx); + } + idx + } + + /// Overwrite the node's payload id โ€” `None` tombstones it in place (the stale payload is + /// retained, id-based lookups stop finding it), `Some` (re)vitalizes it. + pub fn set_node_id(&mut self, idx: usize, id: Option) { + match id { + Some(id) => { + self.tombstoned[idx] = false; + self.set_commit_id(idx, id); + } + None => { + let old = self.nodes[idx].id; + if self.by_id.get(&old) == Some(&idx) { + self.by_id.remove(&old); + } + self.tombstoned[idx] = true; + } + } + } + + /// Rewrite the commit id at `idx` IN PLACE โ€” THE rebase write. The node index, its parent numbers, + /// and its children survive; `by_id`, the children's raw `parent_ids` entries, and the + /// id-addressed markers (entrypoint, managed-ws) follow the payload. + pub fn set_commit_id(&mut self, idx: usize, id: gix::ObjectId) { + let old = self.nodes[idx].id; + self.nodes[idx].id = id; + if self.by_id.get(&old) == Some(&idx) { + self.by_id.remove(&old); + } + self.by_id.insert(id, idx); + for child in self.children[idx].clone() { + for (parent_number, edge) in self.parent_edges[child].iter().enumerate() { + if edge.target_node_idx == Some(idx) { + self.nodes[child].parent_ids[parent_number] = id; + } + } + } + if self.entrypoint == Some(old) { + self.entrypoint = Some(id); + } + if self.managed_ws_commits.remove(&old) { + self.managed_ws_commits.insert(id); + } + } + + /// Overwrite `idx`'s whole parent array โ€” the editor's ordered-parent number write. Every new parent number + /// is PRESENT and CONNECTED; the raw `parent_ids` payload derives from the targets and the + /// children adjacency follows. + pub fn set_parents(&mut self, idx: usize, parents: Vec) { + for edge in std::mem::take(&mut self.parent_edges[idx]) { + if let Some(t) = edge.target_node_idx + && let Some(pos) = self.children[t].iter().position(|&c| c == idx) + { + self.children[t].remove(pos); + } + } + self.nodes[idx].parent_ids = parents.iter().map(|&p| self.nodes[p].id).collect(); + self.parent_edges[idx] = parents + .iter() + .map(|&p| ParentEdge { + target_node_idx: Some(p), + connected: true, + }) + .collect(); + for &p in &parents { + self.children[p].push(idx); + } + } + + /// Reconcile every live node's parents with the odb โ€” the write-through seam's edge refresh; + /// after materialization the odb is authoritative (as-is picks carry no arena edges, + /// `preserved_parents` picks were written with overridden parents). A node is kept only when + /// its RAW parents equal its odb parents AND no connected parent edge targets a tombstone โ€” the + /// projection reads the raw payload, so a stale dropped id is as divergent as a wrong edge. + /// Walk cuts (absent edge targets with odb-true payload) survive; anything else is rewired to the + /// odb parents, adding or reviving missing commits recursively. + pub(crate) fn complete_parents_from_odb( + &mut self, + repo: &crate::walk::overlay::OverlayRepo<'_>, + ) -> anyhow::Result<()> { + let mut queue: Vec = (0..self.node_count()) + .filter_map(|idx| self.node_payload(idx)) + .collect(); + // Tombstone payload โ†’ smallest arena index, consumed on revival โ€” replaces an + // arena scan per odb parent. Stays exact: this loop only revives or adds nodes. + let mut tombstones_by_id: HashMap = HashMap::default(); + for idx in (0..self.nodes.len()).rev() { + if self.tombstoned[idx] { + tombstones_by_id.insert(self.nodes[idx].id, idx); + } + } + while let Some(id) = queue.pop() { + let idx = self.index_of(id).expect("queued ids are live"); + let Ok(commit) = repo.find_commit(id) else { + continue; + }; + let mut odb_parents: Vec<_> = commit.parent_ids().map(|p| p.detach()).collect(); + // Collapse exact duplicate parents like the walk and the graph reader do (a + // workspace merge encodes empty stacks as repeated parents). + if odb_parents.len() > 1 { + let mut deduped = Vec::with_capacity(odb_parents.len()); + for p in odb_parents { + if !deduped.contains(&p) { + deduped.push(p); + } + } + odb_parents = deduped; + } + if self.raw_parent_ids(idx) == odb_parents && !self.has_tombstoned_parent(idx) { + continue; + } + let mut revived = Vec::new(); + let parent_indices = odb_parents + .iter() + .map(|&p| { + if self.index_of(p).is_none() + && let Some(idx) = tombstones_by_id.remove(&p) + { + self.tombstoned[idx] = false; + self.by_id.insert(p, idx); + revived.push(p); + } + self.index_of(p).unwrap_or_else(|| { + queue.push(p); + self.add_node(Some(p)) + }) + }) + .collect(); + self.set_parents(idx, parent_indices); + // A revived node wasn't in the initial sweep โ€” validate it now. Its children need no + // re-queue: any kept child already passed the tombstone-free check. + queue.extend(revived); + } + Ok(()) + } + + /// Clear the walk's GOAL bits (bits beyond [`CommitFlags::all()`](crate::CommitFlags)). + /// Goal numbering is traversal-order ephemera, and nothing after the walk reads goals. + pub(crate) fn strip_goal_flags(&mut self) { + for node in &mut self.nodes { + node.flags &= crate::CommitFlags::all(); + } + } + + /// Drop tombstoned nodes and reindex, leaving an arena indistinguishable from one built + /// without them โ€” a carried graph must not leak editor tombstones to the next consumer. + /// The caller must first ensure no live parent edge targets a tombstone (the seam's odb + /// reconciliation guarantees it); such an edge would degrade to "parent outside the graph". + pub fn compact(&mut self) { + if !self.tombstoned.iter().any(|&t| t) { + return; + } + let mut remap: Vec> = vec![None; self.nodes.len()]; + let mut next = 0; + for (idx, remapped) in remap.iter_mut().enumerate() { + if !self.tombstoned[idx] { + *remapped = Some(next); + next += 1; + } + } + let mut generations = Vec::with_capacity(next); + let mut parent_edges = Vec::with_capacity(next); + for idx in 0..self.nodes.len() { + if remap[idx].is_none() { + continue; + } + generations.push(self.generations[idx]); + parent_edges.push( + self.parent_edges[idx] + .iter() + .map(|edge| ParentEdge { + target_node_idx: edge.target_node_idx.and_then(|t| remap[t]), + connected: edge.connected, + }) + .collect::>(), + ); + } + let nodes: Vec = std::mem::take(&mut self.nodes) + .into_iter() + .enumerate() + .filter_map(|(idx, n)| remap[idx].is_some().then_some(n)) + .collect(); + let by_id = Self::index_by_id(&nodes); + // PRESENT-target children: unlike the walk, a severed-but-present edge keeps its + // child entry here โ€” callers of `children_at` on compacted graphs see it. + let children = Self::derive_children(&parent_edges, false); + self.managed_ws_commits.retain(|id| by_id.contains_key(id)); + self.nodes = nodes; + self.generations = generations; + self.by_id = by_id; + self.parent_edges = parent_edges; + self.children = children; + self.tombstoned = vec![false; next]; + self.rebuild_by_ref(); + } + + /// Arena length, tombstones included. + pub fn node_count(&self) -> usize { + self.nodes.len() + } + + /// The payload id at `idx` โ€” `None` for tombstones. + pub fn node_payload(&self, idx: usize) -> Option { + (!self.tombstoned[idx]).then(|| self.nodes[idx].id) + } + + /// The parent TARGETS of `idx` in parent number order. Only meaningful once every parent number is + /// editor-authored (present) โ€” walk-built graphs can have absent parent numbers. + pub fn parent_indices(&self, idx: usize) -> Vec { + self.parent_edges[idx] + .iter() + .map(|edge| { + edge.target_node_idx + .expect("editor-authored edges are present") + }) + .collect() + } + + /// The PRESENT parent targets of `idx` in parent number order โ€” absent (walk-cut) parent numbers are + /// skipped, unlike [`Self::parent_indices`] which requires every target to be present. + pub fn present_parent_indices(&self, idx: usize) -> Vec { + self.parent_edges[idx] + .iter() + .filter_map(|edge| edge.target_node_idx) + .filter(|&pidx| !self.tombstoned[pidx]) + .collect() + } + + /// Build from a set of commits, every raw edge connected; commits whose parents fall + /// outside the set are roots of this partial subgraph. + #[cfg(test)] + fn from_commits( + commits: impl IntoIterator, + entrypoint: Option, + ) -> Self { + let nodes: Vec = commits.into_iter().collect(); + let by_id = Self::index_by_id(&nodes); + let parent_edges: Vec> = nodes + .iter() + .map(|n| { + n.parent_ids + .iter() + .map(|parent| ParentEdge { + target_node_idx: by_id.get(parent).copied(), + connected: true, + }) + .collect() + }) + .collect(); + let mut table = Self::from_parts(nodes, by_id, parent_edges); + table.entrypoint = entrypoint; + table + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::CommitFlags; + + fn id(b: u8) -> gix::ObjectId { + let mut bytes = [0u8; 20]; + bytes[0] = b; + gix::ObjectId::from_bytes_or_panic(&bytes) + } + + fn commit(b: u8, parents: &[u8]) -> Commit { + Commit { + id: id(b), + parent_ids: parents.iter().map(|&p| id(p)).collect(), + flags: CommitFlags::empty(), + refs: Vec::new(), + } + } + + #[test] + fn children_generation_and_first_parent_walk() { + // Linear: 3 -> 2 -> 1 (child -> parent). + let g = CommitGraph::from_commits( + [commit(3, &[2]), commit(2, &[1]), commit(1, &[])], + Some(id(3)), + ); + assert_eq!(g.first_parent(id(3)), Some(id(2))); + assert_eq!(g.first_parent(id(1)), None); + let idx1 = g.index_of(id(1)).unwrap(); + assert_eq!(g.children_at(idx1), &[g.index_of(id(2)).unwrap()]); + // Generation increases with history depth. + assert_eq!(g.generation_of(id(1)), Some(0)); + assert_eq!(g.generation_of(id(3)), Some(2)); + } +} diff --git a/crates/but-graph/src/commit_graph/merge_base.rs b/crates/but-graph/src/commit_graph/merge_base.rs new file mode 100644 index 00000000000..244433fadb0 --- /dev/null +++ b/crates/but-graph/src/commit_graph/merge_base.rs @@ -0,0 +1,234 @@ +//! The merge-base algorithm: a port of Git's `paint_down_to_common`, kept isolated +//! because it is the trickiest algorithm in the graph. + +use gix::hashtable::HashSet; + +use super::CommitGraph; + +impl CommitGraph { + /// The nearest commit common to `target` and EVERY parent of `merge_commit`. BFS from + /// `merge_commit` over all parents, so the nearest such commit wins. + pub(crate) fn lowest_common_base( + &self, + merge_commit: gix::ObjectId, + target: gix::ObjectId, + ) -> Option { + let mut common = self.ancestor_set(target); + for parent in self.all_parent_ids(merge_commit) { + let parent_ancestors = self.ancestor_set(parent); + common.retain(|c| parent_ancestors.contains(c)); + } + let mut seen = HashSet::default(); + let mut queue = std::collections::VecDeque::from([merge_commit]); + while let Some(c) = queue.pop_front() { + if common.contains(&c) { + return Some(c); + } + if seen.insert(c) { + queue.extend(self.all_parent_ids(c)); + } + } + None + } + + /// The best common ancestor of `a` and `b` in this graph, or `None` if they share no + /// history the walk captured. Criss-cross merges yield several candidate bases; like + /// Git, redundant candidates (reachable from another candidate) are removed and the + /// first surviving base is returned. + pub fn merge_base(&self, a: gix::ObjectId, b: gix::ObjectId) -> Option { + if a == b { + return self.node(a).map(|_| a); + } + let (ia, ib) = (self.index_of(a)?, self.index_of(b)?); + let mut flags = vec![0u8; self.nodes.len()]; + let bases = self.paint_down_to_common(ia, ib, &mut flags); + if bases.is_empty() { + return None; + } + let result = self.remove_redundant(&bases, &mut flags); + result.first().map(|&idx| self.nodes[idx].id) + } + + /// The root-distance ordering [`Self::paint_down_to_common`] wants: this graph's + /// generations are tip-high, the paint wants root-high, so invert. + fn rootward_generation(&self, idx: usize) -> usize { + (u32::MAX - self.generations[idx]) as usize + } + + /// Paint the ancestry of both nodes downward until every candidate common ancestor is + /// found, returning `(node_idx, rootward_generation)` candidates. The algorithm and its + /// flag discipline mirror Git's `paint_down_to_common`. + fn paint_down_to_common( + &self, + first: usize, + second: usize, + flags: &mut [u8], + ) -> Vec<(usize, usize)> { + const SIDE1: u8 = 1 << 0; + const SIDE2: u8 = 1 << 1; + const RESULT: u8 = 1 << 2; + const STALE: u8 = 1 << 3; + + // Priority queue ordered by rootward generation (higher = closer to root = lower + // priority). Reverse makes the max-heap pop tip-closest nodes first. The bool is + // whether the entry counted as non-stale when pushed. + let mut queue: std::collections::BinaryHeap<(std::cmp::Reverse, usize, bool)> = + std::collections::BinaryHeap::new(); + + flags[first] |= SIDE1; + queue.push(( + std::cmp::Reverse(self.rootward_generation(first)), + first, + true, + )); + flags[second] |= SIDE2; + queue.push(( + std::cmp::Reverse(self.rootward_generation(second)), + second, + true, + )); + let mut non_stale = 2usize; + + let mut out = Vec::new(); + + // Keep processing while there are potentially useful entries. Stale entries still + // need to propagate their stale marker; once everything left is stale, no better + // merge-base can be found. The counter over-approximates (an entry pushed non-stale + // may go stale while queued), which only extends the loop past the last useful pop โ€” + // from there every queued node is stale, and a stale node can never become a + // candidate, so the result cannot change. This avoids re-scanning the whole queue + // before every pop. + while non_stale > 0 { + let Some((std::cmp::Reverse(generation), idx, counted)) = queue.pop() else { + break; + }; + if counted { + non_stale -= 1; + } + let node_flags = flags[idx]; + let mut flags_without_result = node_flags & (SIDE1 | SIDE2 | STALE); + + // Reachable from both sides: a merge-base candidate. + if flags_without_result == (SIDE1 | SIDE2) { + if node_flags & RESULT == 0 { + flags[idx] |= RESULT; + out.push((idx, generation)); + } + flags_without_result |= STALE; + } + + for pidx in self.present_parent_indices(idx) { + if (flags[pidx] & flags_without_result) != flags_without_result { + flags[pidx] |= flags_without_result; + let counted = flags[pidx] & STALE == 0; + if counted { + non_stale += 1; + } + queue.push(( + std::cmp::Reverse(self.rootward_generation(pidx)), + pidx, + counted, + )); + } + } + } + + out + } + + /// Remove candidates that are in the history of another candidate, keeping only the + /// topologically most recent ones. Mirrors the segment-level algorithm this replaced. + fn remove_redundant(&self, candidates: &[(usize, usize)], flags: &mut [u8]) -> Vec { + const RESULT: u8 = 1 << 2; + const STALE: u8 = 1 << 3; + if candidates.is_empty() { + return Vec::new(); + } + flags.fill(0); + + let sorted_candidates = { + let mut v = candidates.to_vec(); + // Rootward generation ascending: closer to tips first. + v.sort_by_key(|&(_, generation)| generation); + v + }; + let mut min_gen_pos = 0; + let mut min_gen = sorted_candidates[min_gen_pos].1; + + let mut walk_start: Vec<(usize, usize)> = Vec::with_capacity(candidates.len()); + for &(idx, _) in candidates { + flags[idx] |= RESULT; + for pidx in self.present_parent_indices(idx) { + if flags[pidx] & STALE == 0 { + flags[pidx] |= STALE; + walk_start.push((pidx, self.rootward_generation(pidx))); + } + } + } + walk_start.sort_by_key(|&(idx, _)| idx); + for &(idx, _) in &walk_start { + flags[idx] &= !STALE; + } + + let mut count_still_independent = candidates.len(); + let mut stack: Vec<(usize, usize)> = Vec::new(); + while let Some((idx, generation)) = walk_start.pop() { + if count_still_independent <= 1 { + break; + } + stack.clear(); + flags[idx] |= STALE; + stack.push((idx, generation)); + + while let Some((current, current_gen)) = stack.last().copied() { + if flags[current] & RESULT != 0 { + flags[current] &= !RESULT; + count_still_independent -= 1; + if count_still_independent <= 1 { + break; + } + if current == sorted_candidates[min_gen_pos].0 { + while min_gen_pos < candidates.len() - 1 + && flags[sorted_candidates[min_gen_pos].0] & STALE != 0 + { + min_gen_pos += 1; + } + min_gen = sorted_candidates[min_gen_pos].1; + } + } + if current_gen > min_gen { + stack.pop(); + continue; + } + let previous_len = stack.len(); + for pidx in self.present_parent_indices(current) { + if flags[pidx] & STALE == 0 { + flags[pidx] |= STALE; + stack.push((pidx, self.rootward_generation(pidx))); + } + } + if previous_len == stack.len() { + stack.pop(); + } + } + } + + candidates + .iter() + .filter_map(|&(idx, _)| (flags[idx] & STALE == 0).then_some(idx)) + .collect() + } + + /// [`Self::merge_base`] folded over any number of commits โ€” the octopus variant. + pub fn merge_base_octopus( + &self, + commits: impl IntoIterator, + ) -> Option { + let mut commits = commits.into_iter(); + let mut base = commits.next()?; + for commit in commits { + base = self.merge_base(base, commit)?; + } + Some(base) + } +} diff --git a/crates/but-graph/src/commit_graph_diagnostics.rs b/crates/but-graph/src/commit_graph_diagnostics.rs new file mode 100644 index 00000000000..1364dd911e0 --- /dev/null +++ b/crates/but-graph/src/commit_graph_diagnostics.rs @@ -0,0 +1,183 @@ +//! Diagnostics over the [`CommitGraph`]: aggregate counts, invariant checks, and +//! renderings for debug tooling. These replace the segment-graph diagnostics as the +//! workspace's debug surface โ€” the arena and layout ARE the graph. + +use std::fmt::Write as _; + +use crate::CommitGraph; + +/// Aggregate counts over a [`CommitGraph`], for debug tooling. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct CommitGraphStatistics { + /// The number of live commits. + pub commits: usize, + /// The number of parent edges connected within the graph. + pub edges_connected: usize, + /// The number of parent edges pointing outside the graph โ€” where this subgraph roots. + pub edges_cut: usize, + /// The number of references attached to commits. + pub refs: usize, + /// Commits without children โ€” the graph's tips. + pub commits_at_tip: usize, + /// Commits whose parents are all outside the graph โ€” where this subgraph roots. + pub commits_at_bottom: usize, + /// Commits flagged as reachable from the workspace tip. + pub commits_in_workspace: usize, + /// Commits flagged as reachable from the target. + pub commits_integrated: usize, + /// Commits never owned only by a remote. + pub commits_not_in_remote: usize, + /// The number of positioned references in the ref layout, when stored. + pub layout_refs: Option, + /// Whether traversal stopped early on the hard limit. + pub hard_limit_hit: bool, + /// The entrypoint commit, if set. + pub entrypoint: Option, +} + +impl CommitGraph { + /// Aggregate counts for debug tooling. + pub fn statistics(&self) -> CommitGraphStatistics { + let mut out = CommitGraphStatistics { + hard_limit_hit: self.hard_limit_hit, + entrypoint: self.entrypoint(), + layout_refs: self.layout.as_ref().map(|l| l.facts.len()), + ..Default::default() + }; + for id in self.commit_ids() { + let Some(node) = self.node(id) else { continue }; + out.commits += 1; + out.refs += node.refs.len(); + if self + .index_of(id) + .is_none_or(|idx| self.children_at(idx).is_empty()) + { + out.commits_at_tip += 1; + } + let mut connected_parents = 0; + for parent in self.all_parent_ids(id) { + if self.node(parent).is_some() { + out.edges_connected += 1; + connected_parents += 1; + } else { + out.edges_cut += 1; + } + } + if connected_parents == 0 { + out.commits_at_bottom += 1; + } + let flags = node.flags; + if flags.contains(crate::CommitFlags::InWorkspace) { + out.commits_in_workspace += 1; + } + if flags.contains(crate::CommitFlags::Integrated) { + out.commits_integrated += 1; + } + if flags.contains(crate::CommitFlags::NotInRemote) { + out.commits_not_in_remote += 1; + } + } + out + } + + /// Check index and edge coherence, returning every violation. An empty result means + /// the arena's lookups and adjacency agree with its payloads. + pub fn validation_errors(&self) -> Vec { + let mut out = Vec::new(); + for id in self.commit_ids() { + let Some(node) = self.node(id) else { + out.push(anyhow::anyhow!("commit {id} listed but not resolvable")); + continue; + }; + if node.id != id { + out.push(anyhow::anyhow!( + "commit {id} resolves to node payload {}", + node.id + )); + } + for parent in self.all_parent_ids(id) { + if let Some((pgen, cgen)) = self.generation_of(parent).zip(self.generation_of(id)) + && pgen >= cgen + { + out.push(anyhow::anyhow!( + "parent {parent} of {id} has generation {pgen} >= child generation {cgen}" + )); + } + } + } + if let Some(ep) = self.entrypoint() + && self.node(ep).is_none() + { + out.push(anyhow::anyhow!("entrypoint {ep} is not in the graph")); + } + out + } + + /// One line per commit in generation order (newest first), rendered like the walk's + /// commit debug output. + pub fn debug_string(&self) -> String { + let mut ids: Vec<_> = self + .commit_ids() + .filter_map(|id| self.generation_of(id).map(|generation| (generation, id))) + .collect(); + ids.sort_unstable_by(|a, b| b.cmp(a)); + let mut out = String::new(); + let entrypoint = self.entrypoint(); + for (_, id) in ids { + let Some(node) = self.node(id) else { continue }; + writeln!( + &mut out, + "{}", + crate::debug::commit_debug_string_inner( + node, + entrypoint == Some(id), + None, + self.hard_limit_hit, + false, + ) + ) + .ok(); + } + out + } + + /// The commit graph as a Graphviz `dot` document. Very large graphs are pruned from + /// the bottom so `dot` won't hang. + pub fn dot_graph(&self) -> String { + const LIMIT: usize = 5000; + let mut ids: Vec<_> = self + .commit_ids() + .filter_map(|id| self.generation_of(id).map(|generation| (generation, id))) + .collect(); + ids.sort_unstable_by(|a, b| b.cmp(a)); + if ids.len() > LIMIT { + tracing::warn!( + "Pruning {} commits from the bottom to assure 'dot' won't hang", + ids.len() - LIMIT + ); + ids.truncate(LIMIT); + } + let included: std::collections::HashSet<_> = ids.iter().map(|(_, id)| *id).collect(); + let entrypoint = self.entrypoint(); + let mut dot = String::from("digraph {\n node [shape=box, fontsize=10]\n"); + for &(_, id) in &ids { + let Some(node) = self.node(id) else { continue }; + let label = crate::debug::commit_debug_string_inner( + node, + entrypoint == Some(id), + None, + self.hard_limit_hit, + false, + ) + .replace('"', "\\\""); + writeln!(&mut dot, " \"{id}\" [label=\"{label}\"]").ok(); + for parent in self.all_parent_ids(id) { + if included.contains(&parent) { + writeln!(&mut dot, " \"{id}\" -> \"{parent}\"").ok(); + } + } + } + dot.push_str("}\n"); + dot + } +} diff --git a/crates/but-graph/src/records.rs b/crates/but-graph/src/records.rs new file mode 100644 index 00000000000..a13a670e92d --- /dev/null +++ b/crates/but-graph/src/records.rs @@ -0,0 +1,238 @@ +//! The shared records of the commit graph: the [`Commit`] every arena node carries, the +//! [`RefInfo`]/[`Worktree`] decoration on refs, the walk's [`StopCondition`] and the +//! per-commit [`CommitFlags`]. + +use bitflags::bitflags; +use bstr::{BString, ByteSlice}; + +/// A commit with must useful information extracted from the Git commit itself. +#[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, +} + +/// A structure to inform about a reference which was present at a commit. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct RefInfo { + /// The name of the reference. + pub ref_name: gix::refs::FullName, + /// The peeled commit id the reference pointed to when the graph was built. + /// + /// This is `None` if the reference was known only by name, for example for + /// unborn branches or synthetic segments created without a resolved ref tip. + /// + /// It's useful if the segment with this ref-info instance doesn't actually + /// own a commit, and can't (always) discover it with `Graph::tip_skip_empty()`. + /// Workspace queries use it as a fallback in + /// [`Workspace::tip_commit_id()`](crate::Workspace::tip_commit_id). + pub commit_id: Option, + /// If `Some`, provide information about the worktree that checks out the reference at `ref_name`, + /// i.e. its `HEAD` points to `ref_name` directly or indirectly due to chains of . + /// + /// It is `None` if no worktree needs to be updated if this reference is changed. + pub worktree: Option, +} + +/// Describes which kind of worktree is checked out by a [Ref](RefInfo). +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum WorktreeKind { + /// The main worktree, i.e. the primary workspace associated with this repository, is checked out. + /// + /// It cannot be removed. + Main, + /// The identifier of the worktree, which is always `.git/worktrees/`, + /// indicating that this is a linked worktree that can be removed. + LinkedId(BString), +} + +/// Describes which worktree is checked out and how it relates to the repository that produced the graph. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct Worktree { + /// The kind of worktree that checks out the reference. + pub kind: WorktreeKind, + /// The repository that produced the graph is using this worktree. + /// + /// Only one worktree in a graph should have this flag set. + pub owned_by_repo: bool, +} + +impl Worktree { + /// Produce a string that identifies this instance concisely, and visually distinguishable. + /// `ref_name` is the name from the [`RefInfo`] that owns this worktree, + /// used to deduplicate the name we chose. + /// + /// For example, `refs/heads/foo` in linked worktree id `foo` prints + /// `foo[๐Ÿ“]`, not `foo[๐Ÿ“foo]`. + pub fn debug_string(&self, ref_name: &gix::refs::FullNameRef) -> String { + self.debug_string_with_graph_context(ref_name, false) + } + + /// Like [`Self::debug_string()`], but includes graph-contextual worktree ownership markers. + pub fn debug_string_with_graph_context( + &self, + ref_name: &gix::refs::FullNameRef, + show_owned_by_repo: bool, + ) -> String { + let owned_by_repo = if show_owned_by_repo && self.owned_by_repo { + "@repo" + } else { + "" + }; + self.kind.debug_string(ref_name, owned_by_repo) + } +} + +impl WorktreeKind { + fn debug_string(&self, ref_name: &gix::refs::FullNameRef, owned_by_repo: &str) -> String { + match self { + WorktreeKind::Main => format!("[๐ŸŒณ{owned_by_repo}]"), + WorktreeKind::LinkedId(id) => { + format!( + "[๐Ÿ“{id}{owned_by_repo}]", + id = if ref_name.shorten() != id { + id.as_bstr() + } else { + "".into() + } + ) + } + } + } +} + +impl RefInfo { + /// Produce a string that identifies this instance concisely, and visually distinguishable. + pub fn debug_string(&self) -> String { + let ws = self + .worktree + .as_ref() + .map(|ws| ws.debug_string(self.ref_name.as_ref())) + .unwrap_or_default(); + format!("โ–บ{}{ws}", self.ref_name.shorten()) + } +} + +impl std::fmt::Debug for Commit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let refs = self + .refs + .iter() + .map(|ri| ri.debug_string()) + .collect::>() + .join(", "); + write!( + f, + "Commit({hash}, {flags}{refs})", + hash = self.id.to_hex_with_len(7), + flags = self.flags.debug_string() + ) + } +} + +bitflags! { + /// The reason a segment stops without an outgoing graph edge. + /// + /// Multiple flags can be present at once if multiple conditions apply to the same commit. + #[derive(Default, Debug, Copy, Clone, Eq, PartialEq)] + pub struct StopCondition: u8 { + /// Traversal stopped before following parents due to configured traversal limits. + /// + /// If this was a *hard* limit, the graph may not contain all the *interesting* portions of the commit-graph, + /// see [`hard_limit`](crate::walk::Options::hard_limit) + const Limit = 1 << 0; + /// Traversal reached the first commit in history, which has no parents, and is an orphan. + /// There can be more than one in one graph if unrelated histories were merged. + const FirstCommit = 1 << 1; + /// Traversal reached a Git shallow boundary, as is created with the shallow clone feature. + const ShallowBoundary = 1 << 2; + } +} + +impl StopCondition { + /// Return a concise symbolic representation of this stop condition for debug output. + pub fn debug_string(&self, hard_limit: bool) -> String { + let mut out = String::new(); + if self.contains(StopCondition::Limit) { + out.push_str(if hard_limit { "โŒ" } else { "โœ‚" }); + } + if self.contains(StopCondition::FirstCommit) { + out.push('๐Ÿ'); + } + if self.contains(StopCondition::ShallowBoundary) { + out.push('โ›ฐ'); + } + out + } +} + +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: 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, + /// a property we don't want for this trait. + const NotInRemote = 1 << 0; + /// Following the graph upward will lead to at least one tip that is a workspace. + /// + /// Note that if this flag isn't present, this means the commit isn't reachable + /// from a workspace. + const InWorkspace = 1 << 1; + /// The commit is reachable from either the target branch (usually `refs/remotes/origin/main`). + /// Note that when multiple workspaces are included in the traversal, this flag is set by + /// any of many target branches. + const Integrated = 1 << 2; + /// The commit is listed in the repository's shallow boundary file. + const ShallowBoundary = 1 << 3; + } +} + +impl CommitFlags { + /// Return a less verbose debug string. Goal bits are stripped at the arena + /// boundary, so only the named flags render. + pub fn debug_string(&self) -> String { + if self.is_empty() { + "".into() + } else { + let flags = *self & Self::all(); + let string = format!("{flags:?}"); + let out = &string["CommitFlags(".len()..]; + out[..out.len() - 1] + .to_string() + .replace("NotInRemote", "โŒ‚") + .replace("InWorkspace", "๐Ÿ˜") + .replace("Integrated", "โœ“") + .replace("ShallowBoundary", "โ›ฐ") + .replace(" ", "") + } + } + + /// 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.contains(CommitFlags::NotInRemote) + } +} + +/// Metadata attached to a walk seed (and the branch details derived from it): the ref is +/// either a branch in the workspace or the tip of the workspace itself. +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum SegmentMetadata { + /// The ref is considered a branch in the workspace. + Branch(but_core::ref_metadata::Branch), + /// The ref is considered the tip of the workspace. + Workspace(but_core::ref_metadata::Workspace), +} diff --git a/crates/but-graph/src/walk/mod.rs b/crates/but-graph/src/walk/mod.rs new file mode 100644 index 00000000000..06273416197 --- /dev/null +++ b/crates/but-graph/src/walk/mod.rs @@ -0,0 +1,1347 @@ +//! The walk: observe the repository into a [`CommitGraph`](crate::CommitGraph). +//! +//! Stage one of the pipeline. [`Seed`](crate::walk::Seed)s resolve from `HEAD`, the workspace ref, the +//! target and the stack branches (each with a [`SeedRole`](crate::walk::SeedRole)); the traversal (the private +//! `walker`) expands them under [`Options`](crate::walk::Options) limits and goals; and what it saw becomes +//! data โ€” an arena of commits with ordered parent arrays, every encountered ref attached +//! to its commit, flags settled once the partitions reconcile, and the seeds themselves +//! recorded on the graph for later passes. Nothing here decides workspace structure; +//! the build does that afterwards, from what the walk observed. +//! +//! An [`Overlay`](crate::walk::Overlay) previews unwritten state: extra or dropped references and metadata +//! overrides are merged into every repository/metadata read (the private +//! `overlay::OverlayRepo`/`overlay::OverlayMetadata`), so re-running the walk sees the +//! hypothetical world through the same code path as the real one. + +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::{Context as _, bail, ensure}; +use but_core::{ + RefMetadata, extract_remote_name_and_short_name, + ref_metadata::{self, ProjectMeta}, +}; +use gix::prelude::{ObjectIdExt, ReferenceExt}; +use tracing::instrument; + +use crate::{CommitFlags, SegmentMetadata, workspace::GraphContext}; + +pub(crate) mod utils; +use utils::*; + +pub(crate) mod types; +use types::{Goals, Limit}; + +use crate::walk::overlay::{OverlayMetadata, OverlayRepo}; + +mod remotes; + +pub(crate) mod overlay; +pub(crate) mod walker; + +pub(crate) type Entrypoint = Option<(gix::ObjectId, Option)>; + +/// A resolved commit that seeds graph traversal without requiring it to be +/// discoverable through repository refs or workspace metadata. +/// +/// The traversal accumulates the commit arena; every structural notion built on +/// top of seeds โ€” chains, empties, naming โ€” is decided by the build from the +/// arena and the seed records carried on it. +#[derive(Debug, Clone)] +pub struct Seed { + /// The commit id to start walking from. + pub id: gix::ObjectId, + /// The ref name to assign to the seed segment, if it should be named. + pub ref_name: Option, + /// How this seed participates in traversal. + pub role: SeedRole, + /// Metadata to attach to the initial segment. + pub metadata: Option, + /// Whether this seed is the user-facing traversal entrypoint. + /// + /// There may only be *one such seed*. + /// Other seeds try to connect to any commit reachable from this one. + pub is_entrypoint: bool, + /// Whether the entrypoint segment should remain anonymous even if refs + /// point at the same commit. + pub is_detached: bool, +} + +/// Lifecycle +impl Seed { + /// A minimal seed at `id`: unnamed, not the entrypoint, no metadata, + /// default reachable semantics. + pub fn new(id: gix::ObjectId) -> Self { + Seed { + id, + ref_name: None, + role: SeedRole::default(), + metadata: None, + is_entrypoint: false, + is_detached: false, + } + } + + /// A traversal entrypoint at `id`, named by `ref_name` if the caller has a + /// stable ref for it. + pub fn entrypoint(id: gix::ObjectId, ref_name: Option) -> Self { + Seed::new(id).with_ref_name(ref_name).with_entrypoint() + } + + /// An entrypoint at `id` whose segment should remain detached even if refs + /// point to its commit. + pub fn detached_entrypoint(id: gix::ObjectId) -> Self { + Seed::new(id).with_detached_entrypoint() + } + + /// A non-remote traversal root at `id`, named by `ref_name` if available. + pub fn reachable(id: gix::ObjectId, ref_name: Option) -> Self { + Seed::new(id).with_ref_name(ref_name) + } + + /// A target/integration seed at `id` that bounds or extends traversal + /// context โ€” the part of the graph that [`Self::reachable()`] parts want + /// to integrate with. Named by `ref_name` if available. + pub fn integrated(id: gix::ObjectId, ref_name: Option) -> Self { + Seed::new(id) + .with_ref_name(ref_name) + .with_role(SeedRole::TargetRemote) + } +} + +/// Builder +impl Seed { + /// Set the ref name that will name this seed's segment, bypassing normal ref discovery. + pub fn with_ref_name(mut self, ref_name: Option) -> Self { + self.ref_name = ref_name; + self + } + + /// Set the traversal role for this seed. + pub fn with_role(mut self, role: SeedRole) -> Self { + self.role = role; + self + } + + /// Set whether this seed is the traversal entrypoint. + pub(crate) fn with_is_entrypoint(mut self, is_entrypoint: bool) -> Self { + self.is_entrypoint = is_entrypoint; + self + } + + /// Set whether this seed should use detached entrypoint presentation, which makes it anonymous even + /// if it could receive a name/unambiguous ref otherwise. + pub fn with_is_detached(mut self, is_detached: bool) -> Self { + self.is_detached = is_detached; + self + } + + /// Mark this seed as the traversal entrypoint. + pub fn with_entrypoint(self) -> Self { + self.with_is_entrypoint(true) + } + + /// Mark this entrypoint as detached for segment presentation. + pub(crate) fn with_detached_entrypoint(mut self) -> Self { + self = self.with_is_entrypoint(true).with_is_detached(true); + self + } + + /// Attach metadata to the initial segment created for this seed. + pub fn with_metadata(mut self, metadata: SegmentMetadata) -> Self { + self.metadata = Some(metadata); + self + } +} + +/// Utilities +impl Seed { + /// Whether this seed is commit-only integrated target context, like + /// `extra_target_commit_id` or a persisted workspace target commit โ€” + /// no ref to preserve in the projection. + fn is_anonymous_integrated_target_context(&self) -> bool { + matches!(self.role, SeedRole::TargetRemote) && self.ref_name.is_none() + } + + /// Whether this anonymous integrated target was derived by normalization + /// (from metadata or `extra_target_commit_id`) rather than passed by the + /// caller. Such seeds are limits/context and get ordered and deduplicated + /// as auxiliary work, not as user-visible roots. + fn is_auxiliary_integrated_seed( + &self, + auxiliary_integrated_seed_ids: &BTreeSet, + ) -> bool { + self.is_anonymous_integrated_target_context() + && auxiliary_integrated_seed_ids.contains(&self.id) + } + + /// Whether a named target ref already covers this anonymous target's + /// commit. Keeping both would let the anonymous seed own the commit and + /// leave the named ref as a duplicate empty segment. + fn collapses_into_named_integrated_target( + &self, + named_integrated_target_ids: &BTreeSet, + ) -> bool { + self.is_anonymous_integrated_target_context() + && named_integrated_target_ids.contains(&self.id) + } +} + +/// The role a resolved traversal seed plays when constructing a graph. +/// +/// Roles decide the initial [`CommitFlags`] and `Limit` goals used by the +/// walk. The explicit entrypoint is the shared goal: reachable and integrated +/// seeds seek connection to it by walking history until they encounter the entrypoint's +/// propagated goal flag. +/// +/// Remote-tracking seeds are not modeled as explicit [`SeedRole`] values. They +/// are discovered during traversal from refs found at visited commits and their +/// configured or deduced remote-tracking branches. When such a remote seed is +/// queued, it receives an indirect goal for the local commit where it was +/// discovered, while that local side receives a goal for the remote seed. This +/// reciprocal goal setup lets remote and local tracking histories converge until +/// the graph can connect them. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum SeedRole { + /// A non-remote seed that should be traversed and related to the entrypoint. + /// + /// This seed marks all commits it traverses with [`CommitFlags::NotInRemote`]. + #[default] + Reachable, + /// The workspace ref itself, paired with workspace metadata on [`Seed`]. + /// + /// This marks commits as in-workspace with [`CommitFlags::InWorkspace`]. + Workspace, + /// A branch from a stack listed in workspace metadata. + /// + /// Its current ref tip should be traversed even if it is not reachable from + /// the workspace commit. + WorkspaceStackBranch { + /// Ref name from workspace metadata, used as a naming fallback when + /// the initial segment can't infer an unambiguous ref from the seed + /// commit (e.g. remote-only stack refs). + /// + /// Not [`Seed::ref_name`], which would force the name and bypass + /// normal ref discovery and ambiguity handling. + /// + /// [Seed::id] is assumed to be the peeled commit this ref points to. + desired_ref_name: gix::refs::FullName, + }, + /// A target/integration seed whose reachable history is considered integrated, + /// and that reachable/unintegrated seeds want to connect with. + /// + /// This seed receives [`CommitFlags::Integrated`] and an indirect goal for + /// the entrypoint commit with no extra allowance once that goal is found. It + /// walks just far enough to connect target history to the entrypoint's + /// ancestry. + TargetRemote, + /// The local branch that tracks an integrated target branch. + /// + /// It receives a goal for the target and later provides the segment id that + /// lets the target segment point back to its local sibling. + TargetLocal { + /// The expected local tracking ref, used to verify that the segment + /// ref discovery created really is the local side of this target. + /// + /// Not [`Seed::ref_name`], which would force the name and bypass + /// ambiguity checks: if another local branch shares the seed commit, + /// the segment may represent that branch or stay anonymous, and + /// linking it as the target's local side would point ahead/behind and + /// remote-reachability queries at the wrong segment. + local_ref_name: gix::refs::FullName, + }, +} + +/// Access +impl SeedRole { + /// Whether this role represents integrated history. + pub fn is_integrated(&self) -> bool { + matches!(self, SeedRole::TargetRemote) + } +} + +/// A local branch ref and the commit it points to, when it tracks a workspace +/// target ref. +pub(crate) type LocalTrackingTip = (gix::refs::FullName, gix::ObjectId); + +/// A workspace target ref, its commit, and optionally the local branch tracking it. +pub(crate) type WorkspaceTargetTip = (gix::refs::FullName, gix::ObjectId, Option); + +/// The complete pre-traversal plan derived from either explicit seeds or +/// workspace metadata. +/// +/// `queue_initial_seeds()` consumes this to mint the seed-table records and fill the traversal +/// queue, and provides the auxiliary ref/remote information the traversal and build need. Each +/// seed gets its own (possibly empty) segment during the walk. +struct InitialSeeds { + /// Ordered traversal roots to turn into segments and queue items. + seeds: Vec, + /// Workspace commits used to ensure commits remain owned by the workspace + /// roots that introduced them. + workspace_seeds: Vec, + /// Workspace ref names that should be included while collecting refs by + /// prefix, even when they are not reachable from the entrypoint yet. + workspace_ref_names: Vec, + /// Remote target refs already scheduled as initial integrated seeds, so + /// `try_queue_remote_tracking_branches()` won't queue them again when + /// local branches point at them as upstreams. + // TODO: could this be removed in favor of using `CommitGraph::seeds`? + target_refs: Vec, + /// Remote names to try when a local branch has no configured upstream: + /// `refs/remotes//` is used if that ref exists + /// and isn't configured for another branch. + symbolic_remote_names: Vec, + /// Whether metadata-derived workspace/target seeds should be front-loaded + /// into the traversal queue after their segments are created. + frontload_workspace_related_seeds: bool, + /// Target remote/local tracking links inferred from seed refs and branch + /// config. + /// + /// Needed up front because the two sides may share a commit or arrive in + /// either order: queueing delays the target until the local side has a + /// segment, then links both as siblings before other seeds can claim + /// their commits. + target_local_links: TargetLocalLinks, + /// Anonymous target-remote seeds that are auxiliary traversal context rather + /// than primary target refs. + auxiliary_integrated_seed_ids: BTreeSet, +} + +/// Bidirectional lookup between target remote refs and their local tracking refs. +#[derive(Default)] +struct TargetLocalLinks { + /// Local tracking ref by target remote ref. + local_by_target: BTreeMap, + /// Target remote ref by local tracking ref. + target_by_local: BTreeMap, +} + +/// Unwritten state served from memory instead of the repository when (re)building the graph: +/// extra or dropped references and metadata overrides. +#[derive(Debug, Default, Clone)] +pub struct Overlay { + entrypoint: Entrypoint, + nonoverriding_references: Vec, + overriding_references: Vec, + /// Refs the re-traversal must not pick up โ€” see [`Overlay::with_dropped_references`]. + dropped_references: Vec, + meta_branches: Vec<(gix::refs::FullName, ref_metadata::Branch)>, + branch_stack_orders: Vec>, + workspace: Option<(gix::refs::FullName, ref_metadata::Workspace)>, +} + +/// Options for the graph traversals (`CommitGraph::from_head`, `CommitGraph::from_tip`). +#[derive(Default, Debug, Clone)] +pub struct Options { + /// Associate tag references with commits. + /// + /// If `false`, tags are not collected. + pub collect_tags: bool, + /// 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 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 a hint and not an exact measure, and it's always possible to receive + /// more commits than asked for โ€” e.g. remote branches must be able to find their local + /// branch regardless of the limit. + pub commits_limit_hint: Option, + /// Commits at which the remaining budget resets to `commits_limit_hint` โ€” typically the + /// last commits of partial segments a previous traversal returned. Think of them as + /// refuelling stops that direct where the commit budget is spent. + pub commits_limit_recharge_location: Vec, + /// As opposed to the limit-hint, if not `None` we will stop queuing new commits after pretty much this many + /// commits have been seen. + /// + /// This is a last line of defense against runaway traversals and for now 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. Commits that are already queued are still processed so + /// their existing graph connections can be completed. + /// + /// 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, + /// The tip of one additional, anonymous target โ€” it joins the configured + /// target (never overrides it) wherever targets act: integration marking, + /// and the workspace's lower bound, a merge-base over the stack tips and + /// all target positions where the lowest target decides (`min(targets)`). + /// A past target position thus pulls the floor down, re-revealing the + /// integrated span pruning would otherwise hide; with no configured + /// target it is the sole source of integration. + pub extra_target_commit_id: Option, +} + +/// Presets +impl Options { + /// Return options that won't traverse the whole graph if there is no workspace, but will show + /// more than enough commits by default. + pub fn limited() -> Self { + Options { + collect_tags: false, + commits_limit_hint: Some(300), + ..Default::default() + } + } +} + +/// Builder +impl Options { + /// Set a soft cap on how many commits each seed's walk may traverse. Building consistent, + /// connected graphs takes precedence over the cap. + pub fn with_limit_hint(mut self, limit: usize) -> Self { + self.commits_limit_hint = Some(limit); + self + } + + /// 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. + /// + /// ### Warning + /// + /// This stops traversal early despite not having discovered all desired graph partitions, possibly leading to + /// incorrect results. Ideally, this is not used. + 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, + ) -> Self { + self.commits_limit_recharge_location.extend(commits); + self + } + + /// Set [`Self::extra_target_commit_id`]. Tests use it to nominate a target + /// without remote or metadata setup; production feeds the persisted + /// workspace target commit through the same seeding via metadata. + pub fn with_extra_target_commit_id(mut self, id: impl Into) -> Self { + self.extra_target_commit_id = Some(id.into()); + self + } +} + +/// Lifecycle +impl crate::CommitGraph { + /// Read the `HEAD` of `repo` and represent whatever is visible as a graph. + /// + /// See [`Self::from_tip()`] for details. + pub(crate) fn from_head( + repo: &gix::Repository, + meta: &impl RefMetadata, + project_meta: ProjectMeta, + options: Options, + ) -> anyhow::Result<(crate::CommitGraph, GraphContext)> { + let head = repo.head()?; + // The dispatch lives in `from_tip` (which every case below delegates to): + // a checkout inside a managed workspace โ€” including HEAD on the workspace ref itself โ€” + // builds via the managed builder, everything else via the non-managed one. + let mut is_detached = false; + let (seed, maybe_name) = match head.kind { + gix::head::Kind::Unborn(ref_name) => { + let mut cg = crate::CommitGraph::default(); + // The frame reads the entrypoint ref off the substrate even for a + // commitless graph. + cg.set_entrypoint_ref(ref_name.clone()); + // It's OK to default-initialise this here as overlays are only used when redoing + // the traversal. + let (_repo, meta, _entrypoint) = Overlay::default().into_parts(repo, meta); + let wt_by_branch = { + // Assume linked worktrees are never unborn! + let mut m = BTreeMap::new(); + m.insert( + ref_name.clone(), + vec![crate::Worktree { + kind: crate::WorktreeKind::Main, + owned_by_repo: true, + }], + ); + m + }; + let segment = branch_segment_from_name_and_meta( + Some((ref_name, None)), + &meta, + None, + &wt_by_branch, + )?; + let branch_details = segment + .ref_info + .as_ref() + .map(|ri| { + let details = crate::workspace::BranchDetails { + metadata: segment.metadata.as_ref().and_then(|md| match md { + crate::SegmentMetadata::Branch(md) => Some(md.clone()), + crate::SegmentMetadata::Workspace(_) => None, + }), + worktree: ri.worktree.clone(), + remote_walk_tip: None, + }; + std::iter::once((ri.ref_name.clone(), details)).collect() + }) + .unwrap_or_default(); + let ctx = GraphContext { + project_meta, + branch_details, + ..Default::default() + }; + return Ok((cg, ctx)); + } + gix::head::Kind::Detached { target, peeled } => { + is_detached = true; + (peeled.unwrap_or(target).attach(repo), None) + } + gix::head::Kind::Symbolic(existing_reference) => { + let mut existing_reference = existing_reference.attach(repo); + let seed = existing_reference.peel_to_id()?; + (seed, Some(existing_reference.inner.name)) + } + }; + + let (mut cg, ctx) = Self::from_tip(seed, maybe_name, meta, project_meta, options)?; + if is_detached && let Some(seed) = cg.seeds.iter_mut().find(|t| t.is_entrypoint) { + // Detachment rides on the substrate's entrypoint seed โ€” the + // projection anonymizes the entry from it. + seed.is_detached = true; + } + Ok((cg, ctx)) + } + /// Build the workspace's substrate from the commit at `seed` (`ref_name` is assumed to + /// point to it if given): a managed workspace is discovered on the fly from `meta`, else + /// the non-managed builder runs. + /// + /// Walk rules the traversal owns: + /// * Seeding: workspace metadata resolves into [`Seed`]s and follows the explicit-seeds + /// path. Explicit seeds carry exactly ONE entrypoint, no duplicates, named seeds must + /// resolve to their commit, detached seeds are unnamed entrypoints. Metadata seeds keep + /// their queue order; explicit ones normalize (integrated/target first, entrypoint last). + /// * Flags settle only when the traversal finishes โ€” partitions grow together. + /// * Remote tracking branches are discovered only for refs the walk reached, and never + /// take commits that are already owned. + /// * The traversal cuts short when only integrated seeds remain, but always runs long + /// enough to reconcile possibly disjoint branches. + #[instrument(name = "CommitGraph::from_tip", level = "trace", skip_all, fields(seed = ?seed, ref_name), err(Debug))] + pub(crate) fn from_tip( + seed: gix::Id<'_>, + ref_name: impl Into>, + meta: &impl RefMetadata, + project_meta: ProjectMeta, + options: Options, + ) -> anyhow::Result<(crate::CommitGraph, GraphContext)> { + let repo = seed.repo; + Self::dispatch_tip( + repo, + seed.detach(), + ref_name.into(), + meta, + project_meta, + options, + Overlay::default(), + ) + } + + /// The tip dispatch `from_tip` and [`Workspace::redo`](crate::Workspace::redo) share: + /// inside a managed workspace (a workspace-ref seed is the plain case, any other checkout + /// an entrypoint split), else the non-managed builder. + pub(crate) fn dispatch_tip( + repo: &gix::Repository, + seed: gix::ObjectId, + ref_name: Option, + meta: &impl RefMetadata, + project_meta: ProjectMeta, + options: Options, + overlay: Overlay, + ) -> anyhow::Result<(crate::CommitGraph, GraphContext)> { + let is_ws_tip = ref_name + .as_ref() + .is_some_and(|r| but_core::is_workspace_ref_name(r.as_ref())); + let (entrypoint, entrypoint_ref) = if is_ws_tip { + (None, None) + } else { + (Some(seed), ref_name.clone()) + }; + if let Some(graph) = crate::graph_from_repository( + repo, + meta, + entrypoint, + entrypoint_ref, + project_meta.clone(), + options.clone(), + overlay.clone(), + )? { + return Ok(graph); + } + // No managed workspace, or the entrypoint is outside it: the non-managed builder. + crate::graph_from_repository_unmanaged( + repo, + meta, + seed, + ref_name, + project_meta, + options, + overlay, + ) + } + + /// Produce a graph from already resolved seeds and their traversal roles. + /// + /// This is useful for callers that already know the commits they want to + /// relate, or whose seeds are not represented by durable repository refs or + /// workspace metadata. + /// + /// `seeds` must contain exactly one seed with [`Seed::is_entrypoint`] set. + pub(crate) fn from_seeds( + repo: &gix::Repository, + seeds: impl IntoIterator, + meta: &impl RefMetadata, + project_meta: ProjectMeta, + options: Options, + ) -> anyhow::Result<(crate::CommitGraph, GraphContext)> { + let seeds: Vec<_> = seeds.into_iter().collect(); + // Build from a CommitGraph derived from the same seeds traversal. + crate::graph_from_repository_seeds(repo, meta, seeds, project_meta, options) + } +} + +/// Validate caller-provided seeds: exactly one entrypoint, no duplicate +/// traversal seeds or ref names, detached entrypoints unnamed, and every ref +/// name resolving to its seed's commit. +fn validate_explicit_seeds<'a>( + repo: &OverlayRepo<'_>, + seeds: &'a [Seed], + entrypoint_ref_override: Option<&gix::refs::FullName>, +) -> anyhow::Result<&'a Seed> { + let mut entrypoints = seeds.iter().filter(|seed| seed.is_entrypoint); + let entrypoint = entrypoints + .next() + .context("explicit traversal seeds require exactly one entrypoint")?; + ensure!( + entrypoints.next().is_none(), + "explicit traversal seeds require exactly one entrypoint" + ); + + for (idx, seed) in seeds.iter().enumerate() { + ensure!( + !seed.is_detached || seed.is_entrypoint, + "explicit detached seed must also be the entrypoint" + ); + ensure!( + !seed.is_detached || seed.ref_name.is_none(), + "explicit detached entrypoint seed cannot have a ref name" + ); + ensure!( + !seed.is_entrypoint || matches!(seed.role, SeedRole::Reachable | SeedRole::Workspace), + "explicit entrypoint seed must be reachable or workspace" + ); + + for previous in &seeds[..idx] { + ensure!( + !seeds_have_same_traversal(previous, seed), + "explicit traversal seeds contain duplicate traversal seed {seed:?}" + ); + if let Some(ref_name) = seed + .ref_name + .as_ref() + .filter(|ref_name| previous.ref_name.as_ref() == Some(*ref_name)) + { + bail!("explicit traversal seeds contain duplicate ref name {ref_name}"); + } + } + + if let Some(ref_name) = seed.ref_name.as_ref() { + validate_seed_ref(repo, ref_name, seed.id, "explicit traversal seed ref")?; + } + } + + if !entrypoint.is_detached + && let Some(ref_name) = entrypoint_ref_override + { + validate_seed_ref( + repo, + ref_name, + entrypoint.id, + "explicit traversal entrypoint ref", + )?; + } + + Ok(entrypoint) +} + +fn validate_seed_ref( + repo: &OverlayRepo<'_>, + ref_name: &gix::refs::FullName, + tip_id: gix::ObjectId, + context: &str, +) -> anyhow::Result<()> { + let resolved_id = repo + .try_find_reference(ref_name.as_ref())? + .with_context(|| format!("{context} {ref_name} does not exist"))? + .peel_to_id()? + .detach(); + ensure!( + resolved_id == tip_id, + "{context} {ref_name} points to {resolved_id}, not {tip_id}" + ); + Ok(()) +} + +/// Whether two seeds would seed the same traversal work: same commit id, role, +/// and entrypoint flag. Naming and presentation data are deliberately ignored โ€” +/// they affect the build, but don't make enqueueing the same commit twice useful. +fn seeds_have_same_traversal(previous: &Seed, seed: &Seed) -> bool { + previous.id == seed.id + && seeds_have_same_role(previous, seed) + && previous.is_entrypoint == seed.is_entrypoint +} + +/// Whether two seeds have the same traversal role, for deduplication. +/// +/// Named and anonymous [`SeedRole::TargetRemote`] seeds count as different: +/// a named one represents a ref (segment, target identity, sibling link), an +/// anonymous one only commit-level context. Normalization later collapses the +/// anonymous form into a named seed on the same commit. +fn seeds_have_same_role(previous: &Seed, seed: &Seed) -> bool { + match (&previous.role, &seed.role) { + (SeedRole::TargetRemote, SeedRole::TargetRemote) => { + previous.ref_name.is_some() == seed.ref_name.is_some() + } + _ => previous.role == seed.role, + } +} + +/// Build auxiliary traversal inputs from normalized seeds. +fn assemble_initial_seeds( + repo: &OverlayRepo<'_>, + mut seeds: Vec, + project_meta: &ProjectMeta, + extra_target_commit_id: Option, +) -> InitialSeeds { + let mut auxiliary_integrated_seed_ids = BTreeSet::new(); + if let Some(extra_target) = extra_target_commit_id { + auxiliary_integrated_seed_ids.insert(extra_target); + push_integrated_seed_once(&mut seeds, extra_target); + } + let frontload_workspace_related_seeds = has_workspace_related_seeds(&seeds); + if frontload_workspace_related_seeds { + auxiliary_integrated_seed_ids.extend(seeds.iter().filter_map(|seed| { + seed.is_anonymous_integrated_target_context() + .then_some(seed.id) + })); + } + collapse_anonymous_integrated_seeds_into_named_targets(&mut seeds); + let seeds = seeds_in_queue_order(seeds, &auxiliary_integrated_seed_ids); + let workspace_seeds = seeds + .iter() + .filter(|seed| matches!(seed.role, SeedRole::Workspace)) + .map(|seed| seed.id) + .collect(); + let workspace_ref_names = seeds + .iter() + .filter(|seed| matches!(seed.role, SeedRole::Workspace)) + .filter_map(|seed| seed.ref_name.clone()) + .collect(); + let include_seed_refs = !seeds + .iter() + .any(|seed| matches!(seed.metadata, Some(SegmentMetadata::Workspace(_)))); + let target_refs = target_refs_from_seeds(&seeds, project_meta, include_seed_refs); + let symbolic_remote_names = + symbolic_remote_names_from_seeds(repo, &seeds, project_meta, include_seed_refs); + let target_local_links = target_local_links_from_seeds(repo, &seeds); + + InitialSeeds { + seeds, + workspace_seeds, + workspace_ref_names, + target_refs, + symbolic_remote_names, + frontload_workspace_related_seeds, + target_local_links, + auxiliary_integrated_seed_ids, + } +} + +/// Drop anonymous integrated target seeds whose commit a named integrated +/// target already covers โ€” the named segment should own the commit. +fn collapse_anonymous_integrated_seeds_into_named_targets(seeds: &mut Vec) { + let named_integrated_target_ids = seeds + .iter() + .filter_map(|seed| { + (matches!(seed.role, SeedRole::TargetRemote) && seed.ref_name.is_some()) + .then_some(seed.id) + }) + .collect::>(); + seeds.retain(|seed| !seed.collapses_into_named_integrated_target(&named_integrated_target_ids)); +} + +/// Order validated seeds deterministically โ€” queue order matters because the +/// first item to reach a commit owns its segment. +/// +/// Role priority sets the broad shape, workspace metadata restores stack and +/// branch order where available, and stable tie-breakers make equivalent +/// inputs independent of caller order. Non-workspace traversals keep caller +/// order among equal priorities. +fn seeds_in_queue_order( + seeds: Vec, + auxiliary_integrated_seed_ids: &BTreeSet, +) -> Vec { + let has_workspace_related_seeds = has_workspace_related_seeds(&seeds); + let workspace_branch_order = workspace_branch_order_from_seeds(&seeds); + let mut seeds: Vec<_> = seeds.into_iter().enumerate().collect(); + seeds.sort_by(|(a_idx, a), (b_idx, b)| { + seed_queue_priority( + a, + has_workspace_related_seeds, + auxiliary_integrated_seed_ids, + ) + .cmp(&seed_queue_priority( + b, + has_workspace_related_seeds, + auxiliary_integrated_seed_ids, + )) + .then_with(|| { + seed_metadata_order(a, &workspace_branch_order) + .cmp(&seed_metadata_order(b, &workspace_branch_order)) + }) + .then_with(|| { + if has_workspace_related_seeds { + seed_sort_name(a).cmp(&seed_sort_name(b)) + } else { + std::cmp::Ordering::Equal + } + }) + .then_with(|| { + if has_workspace_related_seeds { + a.id.cmp(&b.id) + } else { + std::cmp::Ordering::Equal + } + }) + .then_with(|| a_idx.cmp(b_idx)) + }); + seeds.into_iter().map(|(_, seed)| seed).collect() +} + +/// Whether seed ordering has to emulate workspace metadata traversal: the +/// relative order of workspace-related seeds decides commit ownership and how +/// virtual workspace/stack segments are minted, so their presence switches +/// sorting from "preserve caller order" to "rebuild metadata order". +fn has_workspace_related_seeds(seeds: &[Seed]) -> bool { + seeds.iter().any(|seed| { + matches!( + seed.role, + SeedRole::Workspace + | SeedRole::TargetLocal { .. } + | SeedRole::WorkspaceStackBranch { .. } + ) || matches!(seed.metadata, Some(SegmentMetadata::Workspace(_))) + }) +} + +/// Primary sort key for initial seeds. For workspace-related traversals, +/// recreate the metadata-derived segment creation order: +/// +/// 1. A non-workspace reachable entrypoint, if there is one. +/// 2. The workspace ref, the traversal anchor. +/// 3. The integrated target ref, then its local tracking branch, so they can +/// be linked as siblings and agree on target ownership. +/// 4. Synthetic integrated targets, like extra target commits. +/// 5. Workspace stack branches (order refined later from metadata). +/// 6. Other reachable roots. +/// +/// For non-workspace traversals there is no metadata order to recover: +/// integrated context first, non-entry reachable roots next, the entrypoint +/// last. Synthetic integrated seeds stay last โ€” auxiliary limits, not roots. +fn seed_queue_priority( + seed: &Seed, + has_workspace_related_seeds: bool, + auxiliary_integrated_seed_ids: &BTreeSet, +) -> usize { + if has_workspace_related_seeds { + match &seed.role { + SeedRole::Reachable if seed.is_entrypoint => 0, + SeedRole::Workspace => 1, + SeedRole::TargetRemote if seed.ref_name.is_some() => 2, + SeedRole::TargetLocal { .. } => 3, + SeedRole::TargetRemote + if seed.is_auxiliary_integrated_seed(auxiliary_integrated_seed_ids) => + { + 4 + } + SeedRole::TargetRemote => 2, + SeedRole::WorkspaceStackBranch { .. } => 5, + SeedRole::Reachable => 6, + } + } else { + match &seed.role { + SeedRole::TargetRemote + if seed.is_auxiliary_integrated_seed(auxiliary_integrated_seed_ids) => + { + 3 + } + SeedRole::TargetRemote => 0, + SeedRole::TargetLocal { .. } => 0, + SeedRole::Reachable | SeedRole::Workspace | SeedRole::WorkspaceStackBranch { .. } => { + if seed.is_entrypoint { + 2 + } else { + 1 + } + } + } + } +} + +/// Recover stack-branch order from workspace metadata โ€” the only reliable way +/// for scrambled explicit input to produce the same graph and projection as +/// `from_tip()`. +/// +/// Maps a branch ref to `(workspace_order, stack_order, branch_order)`: +/// workspaces sorted by their optional ref name (deterministic for +/// multi-workspace input), stacks counted among in-workspace stacks only, +/// branches by position in their stack. Refs not in the map fall back to +/// later tie-breakers; on duplicates the first metadata occurrence wins, +/// matching "first configured stack owns the branch". +fn workspace_branch_order_from_seeds( + seeds: &[Seed], +) -> BTreeMap { + let mut workspaces: Vec<_> = seeds + .iter() + .filter_map(|seed| match seed.metadata.as_ref() { + Some(SegmentMetadata::Workspace(data)) => Some((seed.ref_name.as_ref(), data)), + Some(SegmentMetadata::Branch(_)) | None => None, + }) + .collect(); + workspaces.sort_by_key(|(ref_name, _)| *ref_name); + + let mut out = BTreeMap::new(); + for (workspace_order, (_ref_name, data)) in workspaces.into_iter().enumerate() { + for (stack_order, stack) in data + .stacks + .iter() + .filter(|stack| stack.is_in_workspace()) + .enumerate() + { + for (branch_order, branch) in stack.branches.iter().enumerate() { + out.entry(branch.ref_name.clone()).or_insert(( + workspace_order, + stack_order, + branch_order, + )); + } + } + } + out +} + +/// The metadata order for a workspace stack branch seed; `None` for other +/// roles, which are governed by role priority and later tie-breakers. +fn seed_metadata_order( + seed: &Seed, + workspace_branch_order: &BTreeMap, +) -> Option<(usize, usize, usize)> { + match &seed.role { + SeedRole::WorkspaceStackBranch { desired_ref_name } => { + workspace_branch_order.get(desired_ref_name).copied() + } + SeedRole::Reachable + | SeedRole::Workspace + | SeedRole::TargetRemote + | SeedRole::TargetLocal { .. } => None, + } +} + +/// Stable name tie-breaker for workspace-related sorting: sorting by the ref +/// that will name the segment keeps caller input order irrelevant. Ignored +/// for non-workspace traversals, which preserve caller order. +fn seed_sort_name(seed: &Seed) -> Option { + match &seed.role { + SeedRole::WorkspaceStackBranch { desired_ref_name } => { + Some(desired_ref_name.as_bstr().to_string()) + } + SeedRole::TargetLocal { local_ref_name } => Some(local_ref_name.as_bstr().to_string()), + SeedRole::Reachable | SeedRole::Workspace | SeedRole::TargetRemote => { + seed.ref_name.as_ref().map(|ref_name| ref_name.to_string()) + } + } +} + +/// Discover workspaces, targets, local tracking branches, and workspace stack +/// branch refs and turn them into initial traversal seeds. +pub(crate) fn initial_seeds_from_workspace_metadata( + repo: &OverlayRepo<'_>, + meta: &OverlayMetadata<'_, T>, + entrypoint: gix::ObjectId, + entrypoint_ref: Option<&gix::refs::FullName>, + project_meta: &ProjectMeta, + extra_target_commit_id: Option, +) -> anyhow::Result> { + let workspaces = obtain_workspace_infos(repo, entrypoint_ref.map(|rn| rn.as_ref()), meta)?; + let tip_ref_matches_ws_ref = workspaces + .iter() + .find_map(|(ws_tip, ws_rn, _)| (Some(ws_rn) == entrypoint_ref).then_some(ws_tip)); + + let mut seeds = Vec::new(); + let mut workspace_metas = Vec::new(); + // The metadata's target commit only provides target context for workspaces. + let metadata_target_commit_id = if workspaces.is_empty() { + None + } else { + project_meta.target_commit_id + }; + let mut queued_ids = Vec::new(); + + match tip_ref_matches_ws_ref { + None => { + // We don't name the seed of the entrypoint as we want the segment + // naming to be handled by seeds created from metadata. + seeds.push(Seed::entrypoint(entrypoint, None)); + queued_ids.push(entrypoint); + } + Some(ws_tip) => { + ensure!( + *ws_tip == entrypoint, + format!( + "BUG:: {entrypoint_ref:?} points to {ws_tip}, but the caller claimed it points to {entrypoint}" + ) + ); + } + } + + for (ws_tip, ws_ref, ws_meta) in workspaces { + workspace_metas.push(ws_meta.clone()); + seeds.push( + Seed::new(ws_tip) + .with_ref_name(Some(ws_ref.clone())) + .with_role(SeedRole::Workspace) + .with_metadata(SegmentMetadata::Workspace(ws_meta.clone())) + .with_is_entrypoint(Some(&ws_ref) == entrypoint_ref), + ); + + let target = if let Some((target_ref, target_ref_id, local_info)) = + workspace_target_tip(repo, project_meta.target_ref.as_ref())? + { + let local_info = + local_info.filter(|(_local_ref_name, local_tip)| !queued_ids.contains(local_tip)); + seeds.push( + Seed::new(target_ref_id) + .with_ref_name(Some(target_ref)) + .with_role(SeedRole::TargetRemote), + ); + if let Some((local_ref_name, local_tip)) = local_info.clone() { + seeds + .push(Seed::new(local_tip).with_role(SeedRole::TargetLocal { local_ref_name })); + } + Some(( + target_ref_id, + local_info.map(|(_local_ref_name, local_tip)| local_tip), + )) + } else { + None + }; + queued_ids.push(ws_tip); + if let Some((target_ref_id, local_tip)) = target { + queued_ids.push(target_ref_id); + if let Some(local_tip) = local_tip { + queued_ids.push(local_tip); + } + } + } + + if let Some(extra_target) = extra_target_commit_id { + push_integrated_seed_once(&mut seeds, extra_target); + } + + if let Some(target_commit_id) = metadata_target_commit_id { + // Metadata may be stale โ€” the commit might not exist (anymore). Ignore if that's the case. + if let Err(err) = repo.find_commit(target_commit_id) { + tracing::warn!( + ?target_commit_id, + ?err, + "Ignoring stale target commit id as it didn't exist" + ); + } else { + push_integrated_seed_once(&mut seeds, target_commit_id); + } + } + + // In ad-hoc/single-branch mode the persisted branch order plays the role workspace + // metadata plays for managed stacks: its members must start segments so the ordered + // chain can be planned over boundaries instead of repaired after the fact. Seed them + // like stack branches โ€” reversed, so when several ordered refs share one tip, the + // run's bottom-most ref (the commit owner) provides the segment name. + if let Some(entrypoint_ref) = + entrypoint_ref.filter(|r| r.category() == Some(gix::reference::Category::LocalBranch)) + && let Some(branch_order) = meta.branch_stack_order(entrypoint_ref.as_ref())? + { + for branch in branch_order.into_iter().rev() { + if branch.category() != Some(gix::reference::Category::LocalBranch) { + continue; + } + let Some(tip) = repo + .try_find_reference(branch.as_ref())? + .map(|mut r| r.peel_to_id()) + .transpose()? + else { + continue; + }; + push_seed_once( + &mut seeds, + Seed::new(tip.detach()).with_role(SeedRole::WorkspaceStackBranch { + desired_ref_name: branch, + }), + ); + } + } + + // Queue workspace stack branch refs that may have advanced since the + // workspace commit was written, and thus would not be reached from that + // commit alone. + for ws_metadata in workspace_metas { + for segment in ws_metadata + .stacks + .into_iter() + .filter(|s| s.is_in_workspace()) + .flat_map(|s| s.branches.into_iter()) + { + let Some(segment_tip) = repo + .try_find_reference(segment.ref_name.as_ref())? + .map(|mut r| r.peel_to_id()) + .transpose()? + else { + continue; + }; + push_seed_once( + &mut seeds, + Seed::new(segment_tip.detach()).with_role(SeedRole::WorkspaceStackBranch { + desired_ref_name: segment.ref_name, + }), + ); + } + } + + Ok(seeds) +} + +fn push_integrated_seed_once(seeds: &mut Vec, id: gix::ObjectId) { + let seed = Seed::new(id).with_role(SeedRole::TargetRemote); + push_seed_once(seeds, seed); +} + +fn push_seed_once(seeds: &mut Vec, seed: Seed) { + if !seeds + .iter() + .any(|existing| seeds_have_same_traversal(existing, &seed)) + { + seeds.push(seed); + } +} + +/// Resolve a workspace target ref and, when possible, its local tracking branch +/// tip. +pub(crate) fn workspace_target_tip( + repo: &OverlayRepo<'_>, + target_ref: Option<&gix::refs::FullName>, +) -> anyhow::Result> { + let Some(target_ref) = target_ref else { + return Ok(None); + }; + let target_ref_id = match try_refname_to_id(repo, target_ref.as_ref()).map_err(|err| { + tracing::warn!("Ignoring non-existing target branch {target_ref}: {err}"); + err + }) { + Ok(Some(target_ref_id)) => target_ref_id, + Ok(None) | Err(_) => return Ok(None), + }; + let local_info = repo + .upstream_branch_and_remote_for_tracking_branch(target_ref.as_ref()) + .ok() + .flatten() + .and_then(|(local_tracking_name, _remote_name)| { + let target_local_tip = try_refname_to_id(repo, local_tracking_name.as_ref()).ok()??; + Some((local_tracking_name, target_local_tip)) + }); + Ok(Some((target_ref.clone(), target_ref_id, local_info))) +} + +/// Remote target refs already represented by initial seeds, so +/// remote-tracking discovery won't queue them again. Workspace traversals +/// take these from the project metadata target ref; explicit traversals fall +/// back to named integrated seeds when `include_integrated_seed_refs` is set. +fn target_refs_from_seeds( + seeds: &[Seed], + project_meta: &ProjectMeta, + include_integrated_seed_refs: bool, +) -> Vec { + let has_workspace_metadata_seed = seeds + .iter() + .any(|seed| matches!(seed.metadata, Some(SegmentMetadata::Workspace(_)))); + let mut target_refs: Vec<_> = seeds + .iter() + .filter(|seed| include_integrated_seed_refs && seed.role.is_integrated()) + .filter_map(|seed| seed.ref_name.clone()) + .chain( + has_workspace_metadata_seed + .then(|| project_meta.target_ref.clone()) + .flatten(), + ) + .collect(); + target_refs.sort(); + target_refs.dedup(); + target_refs +} + +/// Infer target remote/local tracking links without exposing correlation ids +/// on public seeds: a named [`SeedRole::TargetRemote`] pairs with the +/// [`SeedRole::TargetLocal`] whose ref is configured to track it. If either +/// side is absent, no sibling link is prepared up front. +fn target_local_links_from_seeds(repo: &OverlayRepo<'_>, seeds: &[Seed]) -> TargetLocalLinks { + let remote_target_refs: Vec<_> = seeds + .iter() + .filter(|seed| matches!(seed.role, SeedRole::TargetRemote)) + .filter_map(|seed| seed.ref_name.clone()) + .collect(); + let local_refs: BTreeSet<_> = seeds + .iter() + .filter_map(|seed| match &seed.role { + SeedRole::TargetLocal { local_ref_name } => Some(local_ref_name.clone()), + SeedRole::Reachable + | SeedRole::Workspace + | SeedRole::WorkspaceStackBranch { .. } + | SeedRole::TargetRemote => None, + }) + .collect(); + + let mut links = TargetLocalLinks::default(); + for target_ref in remote_target_refs { + let Some((local_ref, _remote_name)) = repo + .upstream_branch_and_remote_for_tracking_branch(target_ref.as_ref()) + .ok() + .flatten() + else { + continue; + }; + if !local_refs.contains(&local_ref) { + continue; + } + links + .local_by_target + .insert(target_ref.clone(), local_ref.clone()); + links.target_by_local.insert(local_ref, target_ref); + } + links +} + +/// Collect symbolic remote names implied by seed refs, workspace target refs, +/// workspace `push_remote` settings, and stack branch refs. +fn symbolic_remote_names_from_seeds( + repo: &OverlayRepo<'_>, + seeds: &[Seed], + project_meta: &ProjectMeta, + include_seed_refs: bool, +) -> Vec { + let remote_names = repo.remote_names(); + let refs = seeds + .iter() + .filter_map(|seed| { + include_seed_refs + .then_some(seed.ref_name.as_ref()) + .flatten() + }) + .filter_map({ + let remote_names = &remote_names; + move |ref_name| { + extract_remote_name_and_short_name(ref_name.as_ref(), remote_names) + .map(|(remote, _short_name)| (1, remote)) + } + }); + let workspace_metadata_names = seeds + .iter() + .filter_map(|seed| match seed.metadata.as_ref() { + Some(SegmentMetadata::Workspace(data)) => Some(data), + Some(SegmentMetadata::Branch(_)) | None => None, + }) + .flat_map(|data| { + data.stacks.iter().flat_map(|s| { + s.branches.iter().flat_map(|b| { + extract_remote_name_and_short_name(b.ref_name.as_ref(), &remote_names) + .map(|(remote, _short_name)| (1, remote)) + }) + }) + }); + let desired_refs = seeds.iter().filter_map(|seed| match &seed.role { + _ if !include_seed_refs => None, + SeedRole::WorkspaceStackBranch { desired_ref_name } => { + extract_remote_name_and_short_name(desired_ref_name.as_ref(), &remote_names) + .map(|(remote, _short_name)| (1, remote)) + } + SeedRole::Reachable + | SeedRole::Workspace + | SeedRole::TargetLocal { .. } + | SeedRole::TargetRemote => None, + }); + let target_ref = project_meta.target_ref.as_ref().and_then(|target_ref| { + extract_remote_name_and_short_name(target_ref.as_ref(), &remote_names) + .map(|(remote, _short_name)| (1, remote)) + }); + let push_remote = project_meta + .push_remote + .as_ref() + .map(|push_remote| (0, push_remote.clone())); + sorted_symbolic_remote_names( + refs.chain(workspace_metadata_names) + .chain(desired_refs) + .chain(target_ref) + .chain(push_remote), + ) +} + +/// Sort and deduplicate remote names, preserving explicit push remotes before +/// remotes inferred from refs with the same name. +fn sorted_symbolic_remote_names(names: impl Iterator) -> Vec { + let mut names: Vec<_> = names.collect(); + names.sort(); + names.dedup(); + names.into_iter().map(|(_order, remote)| remote).collect() +} + +/// The second half of the ordering heuristic: `seeds_in_queue_order()` fixes +/// segment creation order, but some roles must also be *visited* first so +/// they own shared commits. +/// +/// Synthetic integrated seeds always front-load (limits, not user roots); +/// workspace, target, and target-local seeds front-load so target ownership +/// and sibling links settle before stack branches can claim shared commits. +/// Stack branches deliberately are not front-loaded โ€” their traversal work +/// should follow the workspace/target context. +fn queue_should_frontload_seed( + seed: &Seed, + frontload_workspace_related_seeds: bool, + auxiliary_integrated_seed_ids: &BTreeSet, +) -> bool { + seed.is_auxiliary_integrated_seed(auxiliary_integrated_seed_ids) + || (frontload_workspace_related_seeds + && matches!( + seed.role, + SeedRole::Workspace | SeedRole::TargetRemote | SeedRole::TargetLocal { .. } + )) +} + +/// Return the flags and limit used by a reachable seed seeking the entrypoint. +fn reachable_seed_flags_and_limit( + seed: gix::ObjectId, + entrypoint: gix::ObjectId, + max_limit: Limit, + goals: &mut Goals, +) -> (CommitFlags, Limit) { + let limit = if seed == entrypoint { + max_limit + } else { + max_limit.with_indirect_goal(entrypoint, goals) + }; + (CommitFlags::NotInRemote, limit) +} diff --git a/crates/but-graph/src/walk/overlay.rs b/crates/but-graph/src/walk/overlay.rs new file mode 100644 index 00000000000..d9715f3bfad --- /dev/null +++ b/crates/but-graph/src/walk/overlay.rs @@ -0,0 +1,874 @@ +//! Every repository and metadata read the walk performs, with unwritten state merged +//! in. [`OverlayRepo`] answers reference lookups and listings with the +//! [`Overlay`](crate::walk::Overlay)'s extra and dropped references applied, and +//! memoizes the expensive enumerations (worktree branches, prefixed raw refs) so one +//! walk reads the disk once; [`OverlayMetadata`] layers branch/workspace metadata +//! overrides the same way. The walk and build touch the repository ONLY through these +//! two โ€” which is what lets an edit preview re-run the identical code over a +//! hypothetical world. + +use std::{ + borrow::Cow, + collections::{BTreeMap, BTreeSet, HashSet}, +}; + +use anyhow::bail; +use but_core::{RefMetadata, ref_metadata}; +use gix::{prelude::ReferenceExt, refs::Target}; + +use crate::{ + Worktree, WorktreeKind, + walk::{ + Entrypoint, Overlay, + utils::{RefsById, WorktreeByBranch}, + }, +}; + +impl Overlay { + /// Serve `refs` from memory as if they existed โ€” but only where no real reference with + /// that name exists (real refs win). + pub fn with_references_if_new( + mut self, + refs: impl IntoIterator, + ) -> Self { + self.nonoverriding_references = refs.into_iter().collect(); + self + } + + /// Serve `refs` from memory, overriding any same-named reference in the repository โ€” as if + /// the reference had been created or set to this value. + pub fn with_references(mut self, refs: impl IntoIterator) -> Self { + self.overriding_references.extend(refs); + self + } + + /// A list of references that should not be picked up anymore in the + /// re-traversal. + /// + /// For example, if the `but_rebase::graph_rebase::Editor` converts a + /// `Reference` step to a `None` step which is the equivalent of running + /// `git update-ref -d`, it should no longer be part of the + /// `Graph`, so we would list the particular reference as a dropped + /// reference. + pub fn with_dropped_references( + mut self, + refs: impl IntoIterator, + ) -> Self { + self.dropped_references.extend(refs); + self + } + + /// Override the starting position of the traversal by setting it to `id`, + /// and optionally, by providing the `ref_name` that points to `id`. + pub fn with_entrypoint( + mut self, + id: gix::ObjectId, + ref_name: Option, + ) -> Self { + if let Some((_id, ref_name)) = self.entrypoint { + self.overriding_references + .retain(|r| Some(&r.name) != ref_name.as_ref()) + } + + if let Some(ref_name) = &ref_name { + self.overriding_references.push(gix::refs::Reference { + name: ref_name.to_owned(), + target: Target::Object(id), + peeled: Some(id), + }) + } + self.entrypoint = Some((id, ref_name)); + self + } + + /// Serve the given `branches` metadata from memory, as if they existed, + /// possibly overriding metadata of a ref that already exists. + pub fn with_branch_metadata_override( + mut self, + refs: impl IntoIterator, + ) -> Self { + self.meta_branches = refs.into_iter().collect(); + self + } + + /// Serve the given workspace `metadata` from memory, as if they existed, + /// possibly overriding metadata of a workspace at that place + pub fn with_workspace_metadata_override( + mut self, + metadata: Option<(gix::refs::FullName, ref_metadata::Workspace)>, + ) -> Self { + self.workspace = metadata; + self + } + + /// Serve the given ad-hoc branch stack order from memory. + pub fn with_branch_stack_order_override( + mut self, + branches: impl IntoIterator, + ) -> Self { + self.branch_stack_orders + .push(branches.into_iter().collect()); + self + } +} + +impl Overlay { + pub(crate) fn into_parts<'repo, 'meta, T>( + self, + repo: &'repo gix::Repository, + meta: &'meta T, + ) -> (OverlayRepo<'repo>, OverlayMetadata<'meta, T>, Entrypoint) + where + T: RefMetadata, + { + let Overlay { + nonoverriding_references, + overriding_references, + dropped_references, + meta_branches, + branch_stack_orders, + workspace, + entrypoint, + } = self; + // Deterministic order: the first mention of a name wins. + fn first_wins_by_name(refs: Vec) -> NameToReference { + let mut map = NameToReference::new(); + for reference in refs { + map.entry(reference.name.clone()).or_insert(reference); + } + map + } + + ( + OverlayRepo { + nonoverriding_references: first_wins_by_name(nonoverriding_references), + overriding_references: first_wins_by_name(overriding_references), + dropped_references: dropped_references.into_iter().collect(), + inner: repo, + worktree_branches_by_referent: Default::default(), + raw_refs_by_prefix: Default::default(), + }, + OverlayMetadata { + inner: meta, + meta_branches, + branch_stack_orders, + workspace, + }, + entrypoint, + ) + } +} + +type NameToReference = BTreeMap; + +pub(crate) struct OverlayRepo<'repo> { + inner: &'repo gix::Repository, + nonoverriding_references: NameToReference, + overriding_references: NameToReference, + dropped_references: BTreeSet, + /// [`Self::worktree_branches`] answers, per referent asked for โ€” enumerating worktrees + /// opens a repository PER linked worktree, and one build asks several times. + worktree_branches_by_referent: + std::cell::RefCell, WorktreeByBranch>>, + /// [`Self::raw_refs_prefixed`] scans, per prefix โ€” loose-ref-heavy namespaces make the + /// iteration expensive, and both the walker and the builder's input gathering consume it. + raw_refs_by_prefix: std::cell::RefCell>, +} + +/// One cached namespace scan: each reference with its direct target id (`None` = symbolic, +/// e.g. `origin/HEAD`). +pub(crate) type RawRefs = std::rc::Rc, gix::refs::FullName)>>; + +/// Functions returning `'repo` values hand out the bare repository; callers must not use it in +/// ways that bypass the overlay. +impl<'repo> OverlayRepo<'repo> { + pub fn commit_graph_if_enabled(&self) -> anyhow::Result> { + Ok(self.inner.commit_graph_if_enabled()?) + } + + pub fn shallow_commits( + &self, + ) -> Result, gix::shallow::read::Error> { + self.inner.shallow_commits() + } + + pub fn try_find_reference( + &self, + ref_name: &gix::refs::FullNameRef, + ) -> anyhow::Result>> { + if self.dropped_references.contains(ref_name) { + Ok(None) + } else if let Some(r) = self.overriding_references.get(ref_name) { + Ok(Some(r.clone().attach(self.inner))) + } else if let Some(rn) = self.inner.try_find_reference(ref_name)? { + Ok(Some(rn)) + } else if let Some(r) = self.nonoverriding_references.get(ref_name) { + Ok(Some(r.clone().attach(self.inner))) + } else { + Ok(None) + } + } + + pub fn find_reference( + &self, + ref_name: &gix::refs::FullNameRef, + ) -> anyhow::Result> { + if self.dropped_references.contains(ref_name) { + bail!( + "Failed to find reference {ref_name} due to it being dropped in the traversal overlay" + ); + } + if let Some(r) = self.overriding_references.get(ref_name) { + return Ok(r.clone().attach(self.inner)); + } + Ok(self + .inner + .find_reference(ref_name) + .or_else(|err| match err { + gix::reference::find::existing::Error::Find(_) => Err(err), + gix::reference::find::existing::Error::NotFound { .. } => { + if let Some(r) = self.nonoverriding_references.get(ref_name) { + Ok(r.clone().attach(self.inner)) + } else { + Err(err) + } + } + })?) + } + + pub fn config_snapshot(&self) -> gix::config::Snapshot<'repo> { + self.inner.config_snapshot() + } + + pub fn branch_remote_tracking_ref_name( + &self, + name: &gix::refs::FullNameRef, + direction: gix::remote::Direction, + ) -> Option< + Result< + Cow<'_, gix::refs::FullNameRef>, + gix::repository::branch_remote_tracking_ref_name::Error, + >, + > { + self.inner.branch_remote_tracking_ref_name(name, direction) + } + + pub fn find_commit(&self, id: gix::ObjectId) -> anyhow::Result> { + Ok(self.inner.find_commit(id)?) + } + + pub(crate) fn for_attach_only(&self) -> &'repo gix::Repository { + self.inner + } + + pub(crate) fn for_find_only(&self) -> &'repo gix::Repository { + self.inner + } + + pub fn remote_names(&self) -> gix::remote::Names<'repo> { + self.inner.remote_names() + } + + pub fn upstream_branch_and_remote_for_tracking_branch( + &self, + name: &gix::refs::FullNameRef, + ) -> anyhow::Result)>> { + Ok(self + .inner + .upstream_branch_and_remote_for_tracking_branch(name)?) + } + + /// Create a mapping of all heads to the object ids they point to. + /// `workspace_ref_names` is the names of all known workspace references. + #[tracing::instrument(level = "trace", skip_all)] + pub(crate) fn collect_ref_mapping_by_prefix<'a>( + &self, + prefixes: impl Iterator, + workspace_ref_names: &[&gix::refs::FullNameRef], + ) -> anyhow::Result { + let mut seen = HashSet::new(); + let ref_filter = |seen: &mut HashSet, + r: gix::Reference<'_>| + -> Option<(gix::ObjectId, gix::refs::FullName)> { + if self.dropped_references.contains(r.name()) { + return None; + } + + if workspace_ref_names.contains(&r.name()) { + return None; + } + let id = r.try_id()?; + let (id, name) = if matches!(r.name().category(), Some(gix::reference::Category::Tag)) { + // TODO: also make use of the tag name (the tag object has its own name) + (id.object().ok()?.peel_tags_to_end().ok()?.id, r.inner.name) + } else { + (id.detach(), r.inner.name) + }; + // This is only for overrides. + seen.insert(name.clone()).then_some((id, name)) + }; + let mut all_refs_by_id = gix::hashtable::HashMap::<_, Vec<_>>::default(); + for prefix in prefixes { + // apply overrides - they are seen first and take the spot of everything. + for (commit_id, git_reference) in self + .overriding_references + .values() + .filter(|rn| rn.name.as_bstr().starts_with(prefix.as_bytes())) + .filter_map(|rn| ref_filter(&mut seen, rn.clone().attach(self.inner))) + { + all_refs_by_id + .entry(commit_id) + .or_default() + .push(git_reference); + } + if prefix == "refs/tags/" { + // Tags peel through their tag object โ€” that needs the live reference, + // so this namespace stays uncached (it is opt-in and small). + for (commit_id, git_reference) in self + .inner + .references()? + .prefixed(prefix)? + .filter_map(Result::ok) + .filter_map(|r| ref_filter(&mut seen, r)) + { + all_refs_by_id + .entry(commit_id) + .or_default() + .push(git_reference); + } + } else { + for (id, name) in self.raw_refs_prefixed(prefix)?.iter() { + if self.dropped_references.contains(name) + || workspace_ref_names.contains(&name.as_ref()) + { + continue; + } + // Symbolic refs (e.g. `origin/HEAD`) have no direct target. + let Some(id) = id else { continue }; + if !seen.insert(name.clone()) { + continue; + } + all_refs_by_id.entry(*id).or_default().push(name.clone()); + } + } + // apply overrides (new only) + for (commit_id, git_reference) in self + .nonoverriding_references + .values() + .filter(|rn| rn.name.as_bstr().starts_with(prefix.as_bytes())) + .filter_map(|rn| ref_filter(&mut seen, rn.clone().attach(self.inner))) + { + all_refs_by_id + .entry(commit_id) + .or_default() + .push(git_reference); + } + } + all_refs_by_id.values_mut().for_each(|v| v.sort()); + Ok(all_refs_by_id) + } + + /// The RAW (overlay-free) references under `prefix` with their direct target ids + /// (`None` = symbolic, e.g. `origin/HEAD`), scanned once and cached โ€” consumers apply + /// their own overlay filtering, exactly as they did over a live iteration. + #[tracing::instrument(level = "trace", skip_all, fields(prefix = prefix))] + pub(crate) fn raw_refs_prefixed(&self, prefix: &str) -> anyhow::Result { + if let Some(hit) = self.raw_refs_by_prefix.borrow().get(prefix) { + return Ok(hit.clone()); + } + let scanned = match scan_refs_fast(self.inner, prefix) { + Some(mut scanned) => { + // Determinism, not semantics: every consumer treats the scan as a set. + scanned.sort_by(|a, b| a.1.cmp(&b.1)); + #[cfg(debug_assertions)] + { + let mut live = live_refs_prefixed(self.inner, prefix)?; + live.sort_by(|a, b| a.1.cmp(&b.1)); + debug_assert_eq!( + scanned, live, + "BUG: the parallel loose-ref scan must equal gix's live iteration" + ); + } + scanned + } + None => live_refs_prefixed(self.inner, prefix)?, + }; + let scanned = std::rc::Rc::new(scanned); + self.raw_refs_by_prefix + .borrow_mut() + .insert(prefix.to_string(), scanned.clone()); + Ok(scanned) + } + + /// Map each ref checked out by a worktree (each `HEAD` and its ref chain) to that worktree. + /// `main_head_referent` substitutes the main worktree's HEAD referent โ€” used when an + /// overlay pretends HEAD is elsewhere; the worktree ref on disk is unaffected. + /// + /// ### Shortcoming + /// + /// Only the first HEAD reference can be remapped โ€” proper in-memory overrides would be + /// needed for more. Callers must not derive `main_head_referent` from an entrypoint that + /// isn't really HEAD; nothing enforces this. + #[tracing::instrument(level = "trace", skip_all, fields(referent = ?main_head_referent))] + pub fn worktree_branches( + &self, + main_head_referent: Option<&gix::refs::FullNameRef>, + ) -> anyhow::Result { + let key = main_head_referent.map(|r| r.to_owned()); + if let Some(cached) = self.worktree_branches_by_referent.borrow().get(&key) { + return Ok(cached.clone()); + } + let computed = self.worktree_branches_uncached(main_head_referent)?; + self.worktree_branches_by_referent + .borrow_mut() + .insert(key, computed.clone()); + Ok(computed) + } + + fn worktree_branches_uncached( + &self, + main_head_referent: Option<&gix::refs::FullNameRef>, + ) -> anyhow::Result { + /// If `main_head_referent` is set, it means this is an overridden reference of the `HEAD` of the repo the graph is built in. + /// If `None`, `head` belongs to another worktree. Completely unrelated to linked or main. + fn maybe_insert_head( + head: Option>, + main_head_referent: Option<&gix::refs::FullNameRef>, + overriding: &NameToReference, + out: &mut WorktreeByBranch, + owned_by_repo: bool, + ) -> anyhow::Result<()> { + let Some((head, wd)) = head.and_then(|head| { + head.repo.worktree().map(|wt| { + ( + head, + Worktree { + kind: match wt.id() { + None => WorktreeKind::Main, + Some(id) => WorktreeKind::LinkedId(id.to_owned()), + }, + owned_by_repo, + }, + ) + }) + }) else { + return Ok(()); + }; + + out.entry("HEAD".try_into().expect("valid")) + .or_default() + .push(wd.clone()); + let mut ref_chain = Vec::new(); + // Is this the repo that the overrides were applied on? + let mut cursor = if let Some(head_name) = main_head_referent { + overriding + .get(head_name) + .map(|overridden_head| overridden_head.clone().attach(head.repo)) + .or_else(|| head.try_into_referent()) + } else { + head.try_into_referent() + }; + while let Some(ref_) = cursor { + ref_chain.push(ref_.name().to_owned()); + if overriding + .get(ref_.name()) + .is_some_and(|r| r.target.try_name() != ref_.target().try_name()) + { + bail!( + "SHORTCOMING: cannot deal with {ref_:?} overridden to a different symbolic name to follow" + ) + } + cursor = ref_.follow().transpose()?; + } + for name in ref_chain { + out.entry(name).or_default().push(wd.clone()); + } + + Ok(()) + } + + let mut map = BTreeMap::new(); + maybe_insert_head( + self.inner.head().ok(), + main_head_referent, + &self.overriding_references, + &mut map, + true, + )?; + + let mut repo_is_linked = false; + let current_dir = std::env::current_dir()?; + let repo_real_path = gix::path::realpath_opts( + self.inner.path(), + ¤t_dir, + gix::path::realpath::MAX_SYMLINKS, + )?; + for proxy in self.inner.worktrees()? { + let repo = proxy.into_repo_with_possibly_inaccessible_worktree()?; + let linked_repo_real_path = gix::path::realpath_opts( + repo.path(), + ¤t_dir, + gix::path::realpath::MAX_SYMLINKS, + )?; + if linked_repo_real_path == repo_real_path { + repo_is_linked = true; + continue; + } + maybe_insert_head( + repo.head().ok(), + None, + &self.overriding_references, + &mut map, + false, + )?; + } + if repo_is_linked && let Ok(main_repo) = self.inner.main_repo() { + maybe_insert_head( + main_repo.head().ok(), + None, + &self.overriding_references, + &mut map, + false, + )?; + } + Ok(map) + } +} + +pub(crate) struct OverlayMetadata<'meta, T> { + inner: &'meta T, + meta_branches: Vec<(gix::refs::FullName, ref_metadata::Branch)>, + branch_stack_orders: Vec>, + workspace: Option<(gix::refs::FullName, ref_metadata::Workspace)>, +} + +impl OverlayMetadata<'_, T> +where + T: RefMetadata, +{ + pub(crate) fn iter_workspaces( + &self, + ) -> impl Iterator { + self.inner + .iter() + .filter_map(Result::ok) + .filter_map(|(ref_name, item)| { + item.downcast::() + .ok() + .map(|ws| (ref_name, ws)) + }) + .map(|(ref_name, ws)| { + if let Some((_ws_ref, ws_override)) = self + .workspace + .as_ref() + .filter(|(ws_ref, _ws_data)| *ws_ref == ref_name) + { + (ref_name, ws_override.clone()) + } else { + (ref_name, (*ws).clone()) + } + }) + } + + pub fn workspace_opt( + &self, + ref_name: &gix::refs::FullNameRef, + ) -> anyhow::Result> { + if let Some((_ws_ref, ws_meta)) = self + .workspace + .as_ref() + .filter(|(ws_ref, _ws_meta)| ws_ref.as_ref() == ref_name) + { + return Ok(Some(ws_meta.clone())); + } + let opt = self.inner.workspace_opt(ref_name)?; + Ok(opt.map(|ws_data| ws_data.clone())) + } + + pub fn branch_opt( + &self, + ref_name: &gix::refs::FullNameRef, + ) -> anyhow::Result> { + if let Some(overlay_branch) = self + .meta_branches + .iter() + .find_map(|(rn, branch)| (rn.as_ref() == ref_name).then(|| branch.clone())) + { + return Ok(Some(overlay_branch)); + } + let opt = self.inner.branch_opt(ref_name)?; + Ok(opt.map(|data| data.clone())) + } + + pub fn branch_stack_order( + &self, + ref_name: &gix::refs::FullNameRef, + ) -> anyhow::Result>> { + if let Some(branches) = self + .branch_stack_orders + .iter() + .find(|branches| branches.iter().any(|branch| branch.as_ref() == ref_name)) + { + return Ok(Some(branches.clone())); + } + self.inner.branch_stack_order(ref_name) + } + + /// The wrapped metadata WITHOUT the overlay โ€” only for callers that re-apply the overlay + /// themselves. + pub(crate) fn for_inner_only(&self) -> &'_ T { + self.inner + } +} + +/// An owned metadata handle for [`OverlayMetadata`]'s [`RefMetadata`] impl: values are cloned out +/// of the overlay or the underlying store, so this view is read-only. +pub(crate) struct OwnedHandle { + name: gix::refs::FullName, + value: V, + is_default: bool, +} + +impl std::ops::Deref for OwnedHandle { + type Target = V; + fn deref(&self) -> &V { + &self.value + } +} + +impl std::ops::DerefMut for OwnedHandle { + fn deref_mut(&mut self) -> &mut V { + &mut self.value + } +} + +impl AsRef for OwnedHandle { + fn as_ref(&self) -> &gix::refs::FullNameRef { + self.name.as_ref() + } +} + +impl ref_metadata::ValueInfo for OwnedHandle { + fn is_default(&self) -> bool { + self.is_default + } +} + +/// A read-only [`RefMetadata`] view that substitutes the overlay's workspace and branch overrides, +/// so overlay-aware graph builders can stand in wherever a `RefMetadata` is expected. +impl RefMetadata for OverlayMetadata<'_, T> +where + T: RefMetadata, +{ + type Handle = OwnedHandle; + + fn iter( + &self, + ) -> impl Iterator)>> { + self.inner.iter().map(|res| { + res.map(|(name, item)| { + if item.is::() + && let Some((_, ws)) = self.workspace.as_ref().filter(|(r, _)| *r == name) + { + return (name, Box::new(ws.clone()) as Box); + } + if item.is::() + && let Some((_, b)) = self.meta_branches.iter().find(|(r, _)| *r == name) + { + return (name, Box::new(b.clone()) as Box); + } + (name, item) + }) + }) + } + + fn workspace( + &self, + ref_name: &gix::refs::FullNameRef, + ) -> anyhow::Result> { + let value = OverlayMetadata::workspace_opt(self, ref_name)?; + Ok(OwnedHandle { + name: ref_name.to_owned(), + is_default: value.is_none(), + value: value.unwrap_or_default(), + }) + } + + fn branch( + &self, + ref_name: &gix::refs::FullNameRef, + ) -> anyhow::Result> { + let value = OverlayMetadata::branch_opt(self, ref_name)?; + Ok(OwnedHandle { + name: ref_name.to_owned(), + is_default: value.is_none(), + value: value.unwrap_or_default(), + }) + } + + fn set_workspace( + &mut self, + _value: &Self::Handle, + ) -> anyhow::Result<()> { + bail!("OverlayMetadata is a read-only view") + } + + fn set_branch(&mut self, _value: &Self::Handle) -> anyhow::Result<()> { + bail!("OverlayMetadata is a read-only view") + } + + fn remove(&mut self, _ref_name: &gix::refs::FullNameRef) -> anyhow::Result { + bail!("OverlayMetadata is a read-only view") + } + + fn rename( + &mut self, + _old_ref_name: &gix::refs::FullNameRef, + _new_ref_name: &gix::refs::FullNameRef, + ) -> anyhow::Result<()> { + bail!("OverlayMetadata is a read-only view") + } + + fn branch_stack_order( + &self, + ref_name: &gix::refs::FullNameRef, + ) -> anyhow::Result>> { + OverlayMetadata::branch_stack_order(self, ref_name) + } +} + +/// The reference implementation of the namespace scan: `gix`'s live iteration. +fn live_refs_prefixed( + repo: &gix::Repository, + prefix: &str, +) -> anyhow::Result, gix::refs::FullName)>> { + Ok(repo + .references()? + .prefixed(prefix)? + .filter_map(Result::ok) + .map(|r| (r.try_id().map(|id| id.detach()), r.inner.name)) + .collect()) +} + +/// A hand-rolled scan of the `prefix` namespace: enumerate the loose ref files, read them +/// on a small thread pool, then add packed entries the loose refs don't shadow. `gix`'s +/// live iteration reads loose refs strictly one file at a time, which dominates graph +/// builds on repositories with tens of thousands of loose refs (~17ยตs per file). +/// +/// Returns `None` for anything but the plain file store shapes this understands โ€” the +/// caller falls back to the live iteration. Symbolic refs surface as `None` ids and +/// unparseable files are skipped, both exactly like the live iteration; the result is +/// unordered (callers treat it as a set). +fn scan_refs_fast( + repo: &gix::Repository, + prefix: &str, +) -> Option, gix::refs::FullName)>> { + if !prefix.starts_with("refs/") || !prefix.ends_with('/') { + return None; + } + // Loose files first: they shadow packed entries. + let root = repo.common_dir().join(prefix); + let mut files = Vec::new(); + if root.is_dir() { + let _span = tracing::trace_span!("enumerate_loose").entered(); + collect_loose_ref_files(&root, prefix.into(), &mut files).ok()?; + } + let parse = |(name, path): (gix::bstr::BString, std::path::PathBuf)| -> Option<(Option, gix::refs::FullName)> { + let name = gix::refs::FullName::try_from(name).ok()?; + let content = std::fs::read(&path).ok()?; + let line = content + .split(|&b| b == b'\n') + .next() + .unwrap_or_default() + .trim_ascii_end(); + if line.starts_with(b"ref: ") { + // Symbolic, like the live iteration's `try_id() == None`. + return Some((None, name)); + } + let id = gix::ObjectId::from_hex(line).ok()?; + Some((Some(id), name)) + }; + let parse_span = tracing::trace_span!("parse_loose", files = files.len()).entered(); + let mut scanned: Vec<(Option, gix::refs::FullName)> = if files.len() < 128 { + files.into_iter().filter_map(parse).collect() + } else { + let threads = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(1) + .min(8); + let chunk_size = files.len().div_ceil(threads); + let mut chunks = Vec::with_capacity(threads); + let mut rest = files; + while rest.len() > chunk_size { + let tail = rest.split_off(chunk_size); + chunks.push(rest); + rest = tail; + } + chunks.push(rest); + std::thread::scope(|scope| { + let handles: Vec<_> = chunks + .into_iter() + .map(|chunk| { + scope.spawn(move || chunk.into_iter().filter_map(parse).collect::>()) + }) + .collect(); + handles + .into_iter() + .flat_map(|h| h.join().expect("ref parsing does not panic")) + .collect() + }) + }; + drop(parse_span); + // Packed entries not shadowed by a loose ref. A packed-refs read error falls back to + // the live iteration so errors surface through one path. + let _span = tracing::trace_span!("merge_packed").entered(); + let loose_names: std::collections::HashSet<&gix::bstr::BStr> = + scanned.iter().map(|(_, name)| name.as_bstr()).collect(); + let mut packed_entries = Vec::new(); + if let Some(buffer) = repo.refs.cached_packed_buffer().ok()? { + for record in buffer.iter_prefixed(prefix.into()).ok()? { + let record = record.ok()?; + if loose_names.contains(record.name.as_bstr()) { + continue; + } + packed_entries.push((Some(record.target()), record.name.to_owned())); + } + } + drop(loose_names); + scanned.extend(packed_entries); + Some(scanned) +} + +/// Recursively list the loose ref files under `dir`, carrying the ref-name prefix built +/// so far. Lock files are skipped; name validation happens at parse time. +fn collect_loose_ref_files( + dir: &std::path::Path, + name_prefix: gix::bstr::BString, + out: &mut Vec<(gix::bstr::BString, std::path::PathBuf)>, +) -> std::io::Result<()> { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let Ok(file_name) = gix::path::os_string_into_bstring(entry.file_name()) else { + continue; + }; + if file_name.ends_with(b".lock") { + continue; + } + let mut name = name_prefix.clone(); + name.extend_from_slice(&file_name); + let path = entry.path(); + // The dirent's type is free; a symlink pays one stat to follow it, like the + // live iteration does. + let mut file_type = entry.file_type()?; + if file_type.is_symlink() { + file_type = std::fs::metadata(&path)?.file_type(); + } + if file_type.is_dir() { + name.push(b'/'); + collect_loose_ref_files(&path, name, out)?; + } else { + out.push((name, path)); + } + } + Ok(()) +} diff --git a/crates/but-graph/src/walk/remotes.rs b/crates/but-graph/src/walk/remotes.rs new file mode 100644 index 00000000000..15990fb5bfd --- /dev/null +++ b/crates/but-graph/src/walk/remotes.rs @@ -0,0 +1,72 @@ +use std::collections::BTreeSet; + +use gix::reference::Category; + +use crate::walk::overlay::OverlayRepo; + +/// Returns the unique names of all remote tracking branches that are configured in the repository. +/// Useful to avoid claiming them for deduction. +#[tracing::instrument(level = "trace", skip_all)] +pub(crate) fn configured_remote_tracking_branches( + repo: &OverlayRepo<'_>, +) -> anyhow::Result> { + let mut out = BTreeSet::default(); + for short_name in repo + .config_snapshot() + .sections_by_name("branch") + .into_iter() + .flatten() + .filter_map(|s| s.header().subsection_name()) + { + let Ok(full_name) = Category::LocalBranch.to_full_name(short_name) else { + continue; + }; + out.extend(lookup_remote_tracking_branch(repo, full_name.as_ref())?); + } + Ok(out) +} + +// Note that despite having multiple candidates for remote names, there can only be one +// remote per branch. +// TODO: remove deduction entirely by properly setting up remotes. +pub(crate) fn lookup_remote_tracking_branch_or_deduce_it( + repo: &OverlayRepo<'_>, + ref_name: &gix::refs::FullNameRef, + symbolic_remote_names: &[String], + configured_remote_tracking_branches: &BTreeSet, +) -> anyhow::Result> { + Ok(lookup_remote_tracking_branch(repo, ref_name)?.or_else(|| { + for symbolic_remote_name in symbolic_remote_names { + // Deduce the ref-name as fallback. + // TODO: remove this - this is only required to support legacy repos that + // didn't setup normal Git remotes. + let remote_tracking_ref_name = format!( + "refs/remotes/{symbolic_remote_name}/{short_name}", + short_name = ref_name.shorten() + ); + let Ok(remote_tracking_ref_name) = + gix::refs::FullName::try_from(remote_tracking_ref_name) + else { + continue; + }; + if configured_remote_tracking_branches.contains(&remote_tracking_ref_name) { + continue; + } + return repo + .find_reference(remote_tracking_ref_name.as_ref()) + .ok() + .map(|remote_ref| remote_ref.name().to_owned()); + } + None + })) +} + +pub(crate) fn lookup_remote_tracking_branch( + repo: &OverlayRepo<'_>, + ref_name: &gix::refs::FullNameRef, +) -> anyhow::Result> { + Ok(repo + .branch_remote_tracking_ref_name(ref_name, gix::remote::Direction::Fetch) + .transpose()? + .map(|rn| rn.into_owned())) +} diff --git a/crates/but-graph/src/walk/types.rs b/crates/but-graph/src/walk/types.rs new file mode 100644 index 00000000000..0eb18ac4fd8 --- /dev/null +++ b/crates/but-graph/src/walk/types.rs @@ -0,0 +1,369 @@ +//! The traversal's pacing machinery: [`Limit`] meters how far a tip may walk, +//! [`Goals`] hands out flag bits for commits that must still be found, [`Queue`] +//! orders the expansion, and [`Instruction`] tells a queued commit how to proceed. + +use std::collections::VecDeque; + +use crate::CommitFlags; + +#[derive(Debug, Copy, Clone)] +pub struct Limit { + inner: Option, + /// The flag bits of the commits this tip still needs to find (one bit per goal, handed out + /// by `Goals`). While any goal is outstanding the commit-count limit is ignored; empty means + /// no goal. + goal: CommitFlags, +} + +/// Lifecycle and builders +impl Limit { + pub fn new(value: Option) -> Self { + Limit { + inner: value, + goal: CommitFlags::empty(), + } + } + + /// Walk without a limit until this tip reaches a commit carrying `goal`'s flag โ€” i.e. a + /// commit that has the goal commit above it. The goal counts as found then and normal + /// limits apply. + /// `goals` are used to keep track of existing bitflags. + /// + /// ### 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(crate) fn with_indirect_goal(mut self, goal: gix::ObjectId, goals: &mut Goals) -> Self { + self.goal = goals.flag_for(goal).unwrap_or_default(); + self + } + + /// Set two or more goals, by setting `goal` directly as previously obtained by [Goals::flag_for()]. + pub(crate) fn additional_goal(mut self, goal: CommitFlags) -> Self { + self.goal |= goal; + self + } + + /// Split the remaining limit evenly across the parents, but give each at least 1 so every + /// parent line shows one commit โ€” enough to see where it stops. Split budgets are never + /// merged back when lines re-unite, so a split loses gas overall. + pub(crate) 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(crate) 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(crate) fn is_exhausted_or_decrement(&mut self, flags: CommitFlags, next: &Queue) -> bool { + // Keep going if the goal wasn't seen yet, unlimited gas. + if let Some(maybe_goal) = self.goal_reachable(flags) + && (maybe_goal.is_empty() || self.set_single_goal_reached_keep_searching(maybe_goal)) + { + return false; + } + // 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 { + /// Mark `goal` as found. Returns `true` while other goals are still outstanding (keep + /// searching); once the last goal is found, the limit is marked done and this returns + /// `false`. The done marker reuses the `Integrated` bit inside the goal field โ€” safe + /// because goal bits never overlap the regular flags. + pub(crate) fn set_single_goal_reached_keep_searching(&mut self, goal: CommitFlags) -> bool { + self.goal.remove(goal); + if self.goal.is_empty() { + self.goal.insert(CommitFlags::Integrated); + false + } else { + true + } + } + + /// If `other`'s limit is higher than ours, adopt it. Nothing else changes. + pub(crate) fn adjust_limit_if_bigger(&mut self, other: Limit) { + match (&mut self.inner, other.inner) { + (inner @ Some(_), None) => *inner = None, + (Some(x), Some(y)) => { + if *x < y { + *x = y; + } + } + (None, None) | (None, Some(_)) => {} + } + } + + pub(crate) fn goal_reached(&self) -> bool { + self.goal_unset() || self.goal.contains(CommitFlags::Integrated) + } + + fn goal_unset(&self) -> bool { + self.goal.is_empty() + } + /// Returns `None` when no goal is outstanding (none set, or already reached). Otherwise the + /// subset of this limit's goal flags present in `flags` โ€” non-empty means the goal commit + /// lies above this commit, so it was reached through it. + #[inline] + pub(crate) fn goal_reachable(&self, flags: CommitFlags) -> Option { + if self.goal_reached() { + None + } else { + Some(flags.intersection(self.goal_flags())) + } + } + + /// Return the goal flags, which may be empty. + pub(crate) 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(crate) fn set_but_keep_goal(&mut self, other: Limit) { + self.inner = other.inner; + } +} + +/// Lifecycle +impl Queue { + pub(crate) fn new_with_limit(limit: Option) -> Self { + Queue { + inner: Default::default(), + count: 0, + max: limit, + hard_limit_hit: false, + exhausted: false, + sorted: false, + } + } +} + +/// A queue to keep track of tips, which additionally counts how much was queued over time. +#[derive(Debug)] +pub struct Queue { + pub inner: VecDeque, + /// The current number of queued items. + count: usize, + /// The maximum number of queuing operations, each representing one commit. + max: Option, + /// Whether the hard limit stopped further queuing at least once. + hard_limit_hit: bool, + /// Whether no more items should be queued for reasons other than the hard limit. + exhausted: bool, + /// Whether new items must maintain `inner` in traversal order. + sorted: bool, +} + +/// Counted queuing +impl Queue { + /// Sort the queue so younger commits are processed first: the traversal then moves back in + /// time as one front instead of racing ahead in disjoint regions, which avoids the + /// `propagate_flags_downward` bottleneck. The trade-off โ€” a tip that misses its goal can + /// overshoot toward the beginning of history โ€” is still cheaper, especially when a + /// commit-graph file exists (an easy 8x on lookups), which is exactly when this matters. + pub fn sort(&mut self) { + if !self.sorted { + self.inner + .make_contiguous() + .sort_by(|a, b| a.0.gen_then_time.cmp(&b.0.gen_then_time)); + self.sorted = true; + } + } + #[must_use] + pub(crate) fn push_back_exhausted(&mut self, item: QueueItem) -> bool { + if self.exhausted || self.record_hard_limit_if_exhausted() { + return true; + } + self.push_back_even_if_exhausted(item) + } + + pub(crate) fn push_back_even_if_exhausted(&mut self, item: QueueItem) -> bool { + if self.sorted { + self.insert_sorted(item); + } else { + self.inner.push_back(item); + } + self.is_exhausted_after_increment() + } + #[must_use] + pub(crate) fn push_front_exhausted(&mut self, item: QueueItem) -> bool { + if self.exhausted || self.record_hard_limit_if_exhausted() { + return true; + } + if self.sorted { + self.insert_sorted(item); + } else { + self.inner.push_front(item); + } + self.is_exhausted_after_increment() + } + + fn insert_sorted(&mut self, item: QueueItem) { + let index = self + .inner + .partition_point(|existing| existing.0.gen_then_time <= item.0.gen_then_time); + self.inner.insert(index, item); + } + + fn is_exhausted_after_increment(&mut self) -> bool { + self.count += 1; + self.exhausted || self.record_hard_limit_if_exhausted() + } + + pub(crate) fn is_exhausted(&self) -> bool { + self.exhausted || self.is_hard_limit_exhausted() + } + + pub(crate) fn is_hard_limit_exhausted(&self) -> bool { + self.max.is_some_and(|l| self.count >= l) + } + + pub(crate) fn hard_limit_hit(&self) -> bool { + self.hard_limit_hit + } + + fn record_hard_limit_if_exhausted(&mut self) -> bool { + let hard_limit_exhausted = self.is_hard_limit_exhausted(); + self.hard_limit_hit |= hard_limit_exhausted; + hard_limit_exhausted + } + + /// Stop accepting new items while leaving already queued items to drain. + pub(crate) fn exhaust(&mut self) { + self.exhausted = true; + } + + /// Add `goal` as additional goal to `id` or panic if `id` was not found. + pub(crate) fn add_goal_to(&mut self, id: gix::ObjectId, goal: CommitFlags) { + let limit = self + .inner + .iter_mut() + .find_map(|(info, _, _, limit)| (info.id == id).then_some(limit)) + .unwrap_or_else(|| panic!("BUG: {id} is queued")); + *limit = limit.additional_goal(goal); + } +} + +/// Plain access to the queued items. +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() + } +} +/// A set of commits to keep track of in bitflags. +#[derive(Default)] +pub struct Goals(Vec); + +impl Goals { + /// Return the bitflag for `goal`, or `None` if we can't track any more goals. + pub(crate) 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), + )) + } + } +} + +/// The traversal queue's per-item payload: which stored commit queued this one (edges are +/// recorded at the PARENT's dequeue, so pruned items leave no edge), which seed-table entry it +/// belongs to (initial tips and remote tips; used for the initial sort, workspace-ownership +/// shuffles, and remote dedupe), and whether it starts a new segment. +#[derive(Debug, Clone, Copy)] +pub struct Instruction { + pub queued_by: Option, + pub seed: Option, + /// Queued as one of โ‰ฅ2 parents of a merge commit. + pub new_segment: bool, +} + +/// One queued traversal step. +pub type QueueItem = (super::utils::TraverseInfo, CommitFlags, Instruction, Limit); + +#[cfg(test)] +mod tests { + use super::Queue; + + #[test] + fn explicit_exhaustion_does_not_count_as_hard_limit_hit() { + let mut queue: Queue = Queue::new_with_limit(Some(1)); + + queue.exhaust(); + + assert!(queue.is_exhausted(), "explicit exhaustion stops queueing"); + assert!( + !queue.record_hard_limit_if_exhausted(), + "hard-limit state stays separate from explicit exhaustion" + ); + assert!( + !queue.hard_limit_hit(), + "explicit exhaustion must not mark the hard limit as hit" + ); + } + + #[test] + fn hard_limit_exhaustion_records_hard_limit_hit() { + let mut queue: Queue = Queue::new_with_limit(Some(0)); + + assert!( + queue.record_hard_limit_if_exhausted(), + "a depleted hard limit stops queueing" + ); + assert!( + queue.hard_limit_hit(), + "the queue records when the hard limit stopped queueing" + ); + } +} diff --git a/crates/but-graph/src/walk/utils/mod.rs b/crates/but-graph/src/walk/utils/mod.rs new file mode 100644 index 00000000000..9a50e4c6264 --- /dev/null +++ b/crates/but-graph/src/walk/utils/mod.rs @@ -0,0 +1,361 @@ +//! Utilities for graph-walking specifically. +use std::{cmp::Ordering, collections::BTreeMap, ops::Deref}; + +use but_core::{RefMetadata, ref_metadata}; +use gix::{reference::Category, traverse::commit::Either}; + +use crate::{ + CommitFlags, SegmentMetadata, Worktree, + walk::overlay::{OverlayMetadata, OverlayRepo}, +}; + +pub(crate) type RefsById = gix::hashtable::HashMap>; + +/// 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. +/// Note that `ref_name` should only be set if you are sure that it is unambiguous, and otherwise won't interfere with +/// the graph build or the workspace projection later. +pub(crate) fn branch_segment_from_name_and_meta( + ref_name: Option<(gix::refs::FullName, Option)>, + meta: &OverlayMetadata<'_, T>, + refs_by_id_lookup: Option<(&RefsById, gix::ObjectId)>, + worktree_by_branch: &WorktreeByBranch, +) -> anyhow::Result { + let commit_id = refs_by_id_lookup.map(|(_, id)| id); + let (ref_name, metadata) = + unambiguous_local_branch_and_segment_data(ref_name, meta, refs_by_id_lookup)?; + Ok(SeedSegment { + metadata, + ref_info: ref_name.map(|rn| crate::RefInfo::from_ref(rn, commit_id, worktree_by_branch)), + }) +} + +/// The walk's per-prospective-segment carrier: the (disambiguated name, metadata) +/// pair the traversal decides per seed and remote tip. +#[derive(Debug, Default, Clone)] +pub(crate) struct SeedSegment { + pub ref_info: Option, + pub metadata: Option, +} + +impl SeedSegment { + /// The name this carrier resolved, if any. + pub fn ref_name(&self) -> Option<&gix::refs::FullNameRef> { + self.ref_info.as_ref().map(|ri| ri.ref_name.as_ref()) + } + + /// The workspace metadata this carrier resolved, if it governs one. + pub fn workspace_metadata(&self) -> Option<&but_core::ref_metadata::Workspace> { + match self.metadata.as_ref()? { + SegmentMetadata::Workspace(md) => Some(md), + SegmentMetadata::Branch(_) => None, + } + } +} + +fn unambiguous_local_branch_and_segment_data( + ref_name: Option<(gix::refs::FullName, Option)>, + meta: &OverlayMetadata<'_, T>, + 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_with_lookup(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) + } + }) +} + +pub(crate) fn disambiguate_refs_by_branch_metadata_with_lookup( + refs_by_id_lookup: (&RefsById, gix::ObjectId), + meta: &OverlayMetadata<'_, T>, +) -> Option<(gix::refs::FullName, Option)> { + let (refs_by_id, id) = refs_by_id_lookup; + let branches = refs_by_id + .get(&id)? + .iter() + .filter(|rn| rn.category() == Some(Category::LocalBranch)) + .map(|rn| { + ( + rn, + extract_local_branch_metadata(rn.as_ref(), meta) + .ok() + .flatten(), + ) + }) + .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 mut iter = branches.iter(); + iter.next() + .filter(|_| iter.next().is_none()) + .map(|(rn, md)| (*rn, md.as_ref())) + }) + .map(|(rn, md)| (rn.clone(), md.cloned())) +} + +fn extract_local_branch_metadata( + ref_name: &gix::refs::FullNameRef, + meta: &OverlayMetadata<'_, T>, +) -> anyhow::Result> { + if ref_name.category() != Some(Category::LocalBranch) { + return Ok(None); + } + meta.branch_opt(ref_name) + .map(|res| res.map(SegmentMetadata::Branch)) + .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, Clone)] +pub struct TraverseInfo { + inner: gix::traverse::commit::Info, + /// A means of sorting the entry on the queue. + pub(crate) gen_then_time: GenThenTime, +} + +#[derive(Debug, Clone)] +pub(crate) struct GenThenTime { + /// The generation number from the commit-graph cache, if there was one. + generation: Option, + /// The committer timestamp, either from the commit-graph cache, or as parsed from the commit. + committer_time: u64, +} + +impl Eq for GenThenTime {} + +impl PartialEq for GenThenTime { + fn eq(&self, other: &Self) -> bool { + self.cmp(other).is_eq() + } +} + +impl PartialOrd for GenThenTime { + fn partial_cmp(&self, other: &Self) -> Option { + self.cmp(other).into() + } +} + +/// Sort it so younger generations sort first, with more recent times (i.e. higher) as tiebreaker. +/// When the generation is unknown (`None`), treat it as `u32::MAX` (youngest possible) to match +/// git's `GENERATION_NUMBER_INFINITY` convention, ensuring unknown-generation commits are processed +/// first during traversal. +impl Ord for GenThenTime { + fn cmp(&self, other: &Self) -> Ordering { + // Using a fixed sentinel for `None` is necessary to maintain a total order + // โ€” the previous approach of falling back to time-only comparison when generations were mixed + // violated transitivity. + let gen_a = self.generation.unwrap_or(u32::MAX); + let gen_b = other.generation.unwrap_or(u32::MAX); + gen_a + .cmp(&gen_b) + .reverse() + .then_with(|| self.committer_time.cmp(&other.committer_time).reverse()) + } +} + +impl Deref for TraverseInfo { + type Target = gix::traverse::commit::Info; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +pub fn find( + cache: Option<&gix::commitgraph::Graph>, + objects: &impl gix::objs::Find, + id: gix::ObjectId, + buf: &mut Vec, +) -> anyhow::Result { + let mut parent_ids = gix::traverse::commit::ParentIds::new(); + let gen_then_time = match gix::traverse::commit::find(cache, objects, &id, buf)? { + Either::CachedCommit(c) => { + let cache = cache.expect("cache is available if a cached commit is returned"); + for parent_id in c.iter_parents() { + match parent_id { + Ok(pos) => parent_ids.push({ + let parent = cache.commit_at(pos); + parent.id().to_owned() + }), + Err(_err) => { + // retry without cache + return find(None, objects, id, buf); + } + } + } + GenThenTime { + generation: c.generation().into(), + committer_time: c.committer_timestamp(), + } + } + Either::CommitRefIter(iter) => { + let mut committer_time = None; + for token in iter { + use gix::objs::commit::ref_iter::Token; + match token { + Ok(Token::Tree { .. }) => continue, + Ok(Token::Parent { id }) => { + parent_ids.push(id); + } + Ok(Token::Author { .. }) => continue, + Ok(Token::Committer { signature }) => { + committer_time = Some( + signature + .time() + .map(|t| t.seconds as u64) + .unwrap_or_default(), + ) + } + Ok(_other_tokens) => break, + Err(err) => return Err(err.into()), + }; + } + GenThenTime { + generation: None, + committer_time: committer_time.unwrap_or_default(), + } + } + }; + + // Collapse EXACT duplicate parents right after reading the commit (a GitButler workspace merge + // encodes empty stacks as repeated parents, e.g. `[base, base]`). Stacks are derived from workspace + // metadata, so the repeated edge is pure redundancy; dropping it here keeps the graph from emitting + // a second connection to the same segment. Distinct parents (real merges) are preserved in order. + // Matches the `CommitGraph` reader so both builders agree. + if parent_ids.len() > 1 { + let mut deduped = gix::traverse::commit::ParentIds::new(); + for p in parent_ids.iter() { + if !deduped.iter().any(|seen| seen == p) { + deduped.push(*p); + } + } + if deduped.len() != parent_ids.len() { + parent_ids = deduped; + } + } + + Ok(TraverseInfo { + inner: gix::traverse::commit::Info { + id, + parent_ids, + commit_time: None, + }, + gen_then_time, + }) +} + +/// Returns `[(workspace_tip, workspace_ref_name, workspace_info)]` for all available workspace, +/// or exactly one workspace if `maybe_ref_name` has workspace metadata (and only then). +/// +/// 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, or those that don't have a tip. +pub(crate) fn obtain_workspace_infos( + repo: &OverlayRepo<'_>, + maybe_ref_name: Option<&gix::refs::FullNameRef>, + meta: &OverlayMetadata<'_, T>, +) -> anyhow::Result> { + let workspaces = if let Some((ref_name, ws_data)) = maybe_ref_name + .and_then(|ref_name| { + meta.workspace_opt(ref_name) + .transpose() + .map(|res| res.map(|ws_data| (ref_name, ws_data))) + }) + .transpose()? + { + vec![(ref_name.to_owned(), ws_data)] + } else { + meta.iter_workspaces().collect() + }; + + let mut out = Vec::new(); + for (rn, data) in workspaces { + if rn.category() != Some(Category::LocalBranch) { + tracing::warn!( + "Skipped workspace at ref {rn} as workspaces can only ever be on normal branches", + ); + continue; + } + let Some(ws_tip) = try_refname_to_id(repo, rn.as_ref())? else { + tracing::warn!( + "Ignoring stale workspace ref '{rn}', which didn't exist in Git but still had workspace data", + ); + continue; + }; + + out.push((ws_tip, rn, data)) + } + + Ok(out) +} + +pub(crate) fn try_refname_to_id( + repo: &OverlayRepo<'_>, + refname: &gix::refs::FullNameRef, +) -> anyhow::Result> { + Ok(repo + .try_find_reference(refname)? + .map(|mut r| r.peel_to_id()) + .transpose()? + .map(|id| id.detach())) +} + +pub(crate) struct RemoteQueueOutcome { + /// The new tips to queue officially later. + pub items_to_queue_later: Vec, + /// A way for the remote to find the local tracking branch. + pub maybe_make_id_a_goal_so_remote_can_find_local: CommitFlags, + /// A way for the local tracking branch to find the remote. + /// Only set if `items_to_queue_later` is also set. + pub limit_to_let_local_find_remote: CommitFlags, +} + +pub(crate) type WorktreeByBranch = BTreeMap>; + +impl crate::RefInfo { + pub(crate) fn from_ref( + ref_name: gix::refs::FullName, + commit_id: impl Into>, + worktree_by_branch: &WorktreeByBranch, + ) -> Self { + let worktree = worktree_by_branch + .get(&ref_name) + .and_then(|worktrees| worktrees.first().cloned()); + Self { + ref_name, + commit_id: commit_id.into(), + worktree, + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/but-graph/src/walk/utils/tests.rs b/crates/but-graph/src/walk/utils/tests.rs new file mode 100644 index 00000000000..b0478561290 --- /dev/null +++ b/crates/but-graph/src/walk/utils/tests.rs @@ -0,0 +1,119 @@ +use super::*; + +fn gtt(generation: Option, committer_time: u64) -> GenThenTime { + GenThenTime { + generation, + committer_time, + } +} + +#[test] +fn gen_then_time_total_ordering_is_transitive_with_mixed_generations() { + // This is the exact scenario that previously caused a panic: + // "user-provided comparison function does not correctly implement a total order" + // + // With the old implementation (fall back to time-only when generations are mixed): + // A < B (by time: 200 > 150) + // B < C (by time: 150 > 100) + // A > C (by generation: 5 > 3) โ€” transitivity violation! + let a = gtt(Some(3), 200); + let b = gtt(None, 150); + let c = gtt(Some(5), 100); + + let ab = a.cmp(&b); + let bc = b.cmp(&c); + let ac = a.cmp(&c); + + // With the fix, None is treated as u32::MAX (youngest), so B sorts first. + // B(gen=MAX) > A(gen=3) โ†’ B < A (reversed) + // B(gen=MAX) > C(gen=5) โ†’ B < C (reversed) + // C(gen=5) > A(gen=3) โ†’ C < A (reversed) + // Order: B < C < A โ€” fully transitive. + assert_eq!( + ab, + Ordering::Greater, + "A should sort after B (B has None โ†’ u32::MAX)" + ); + assert_eq!( + bc, + Ordering::Less, + "B should sort before C (B has None โ†’ u32::MAX)" + ); + assert_eq!( + ac, + Ordering::Greater, + "A should sort after C (gen 5 > gen 3)" + ); +} + +#[test] +fn gen_then_time_none_generation_treated_as_youngest() { + // None generation maps to u32::MAX, which reversed sorts first (youngest). + let with_gen = gtt(Some(100), 500); + let without_gen = gtt(None, 500); + assert_eq!( + without_gen.cmp(&with_gen), + Ordering::Less, + "None generation (u32::MAX) should sort before any known generation" + ); +} + +#[test] +fn gen_then_time_both_some_sorts_by_generation_then_time() { + let young_gen = gtt(Some(10), 100); + let old_gen = gtt(Some(2), 200); + assert_eq!( + young_gen.cmp(&old_gen), + Ordering::Less, + "Higher generation sorts first (reversed), regardless of time." + ); + + let recent = gtt(Some(5), 300); + let old = gtt(Some(5), 100); + assert_eq!( + recent.cmp(&old), + Ordering::Less, + "Equal generation falls back to time (higher time sorts first)." + ); +} + +#[test] +fn gen_then_time_both_none_sorts_by_time() { + let recent = gtt(None, 300); + let old = gtt(None, 100); + assert_eq!( + recent.cmp(&old), + Ordering::Less, + "Higher time sorts first (reversed)." + ); + assert_eq!( + gtt(None, 100).cmp(>t(None, 100)), + Ordering::Equal, + "Equal time โ†’ equal." + ); +} + +#[test] +fn gen_then_time_sort_is_deterministic_and_total_issue_12343() { + // Throw a mix of items at sort and ensure it doesn't panic. + // This directly exercises the code path from the stack trace. + let mut items = [ + gtt(Some(3), 200), + gtt(None, 150), + gtt(Some(5), 100), + gtt(None, 300), + gtt(Some(1), 300), + gtt(Some(5), 200), + gtt(None, 100), + gtt(Some(3), 100), + ]; + items.sort(); + + // Verify the result is actually sorted (each element โ‰ค the next). + for window in items.windows(2) { + assert!( + window[0].cmp(&window[1]) != Ordering::Greater, + "Sort result is not ordered: {window:?}" + ); + } +} diff --git a/crates/but-graph/src/walk/walker.rs b/crates/but-graph/src/walk/walker.rs new file mode 100644 index 00000000000..b1a2a2bb7e6 --- /dev/null +++ b/crates/but-graph/src/walk/walker.rs @@ -0,0 +1,1115 @@ +//! The walker: the traversal that accumulates the [`CommitGraph`](crate::CommitGraph) โ€” +//! commits in visit order with ordered parent arrays, refs attached as data (per-commit +//! ref order canonicalized by name), flags propagated until the partitions reconcile. +//! [`Queue`], [`Limit`] and [`Goals`] pace the expansion. +//! +//! Seeding decisions (naming, metadata, workspace ownership) delegate to +//! [`branch_segment_from_name_and_meta`] over throwaway [`SeedSegment`] carriers in a +//! side table โ€” the seed table โ€” so the initial-queue sort and ownership shuffles run +//! one code path whether or not a graph exists yet. + +use gix::hashtable::HashMap; + +use but_core::RefMetadata; +use gix::reference::Category; + +use super::utils::SeedSegment; +use super::{ + InitialSeeds, Options, Seed, SeedRole, + overlay::{OverlayMetadata, OverlayRepo}, + types::{Goals, Instruction, Limit, Queue, QueueItem}, + utils::{RemoteQueueOutcome, WorktreeByBranch, branch_segment_from_name_and_meta, find}, +}; +use crate::{Commit, CommitFlags}; + +/// What the walk produces; [`CommitGraph`](crate::CommitGraph) assembly input. +pub(crate) struct WalkOutcome { + /// Commits in collection order, flags final. + pub commits: Vec, + /// Each commit's index in `commits`, as maintained by the walk. + pub by_id: HashMap, + /// The parents the traversal actually connected, per child. + pub parents_followed: HashMap>, + pub entrypoint: Option, + pub entrypoint_ref: Option, + pub hard_limit_hit: bool, + /// The normalized traversal seeds, carried onto the graph for later passes. + pub seeds: Vec, +} + +/// Commit store: flags mutable by id, children derived from followed edges. +#[derive(Default)] +struct Store { + commits: Vec, + by_id: HashMap, + /// Followed parents per child โ€” edges point down the history. + parents_followed: HashMap>, +} + +impl Store { + fn flags(&self, id: gix::ObjectId) -> CommitFlags { + self.by_id + .get(&id) + .map(|&ix| self.commits[ix].flags) + .unwrap_or_default() + } + fn flags_or(&mut self, id: gix::ObjectId, add: CommitFlags) { + if let Some(&ix) = self.by_id.get(&id) { + self.commits[ix].flags |= add; + } + } + fn push(&mut self, commit: Commit) { + self.by_id.insert(commit.id, self.commits.len()); + self.commits.push(commit); + } + /// Record that the traversal CONNECTED `child -> parent`. Parents per child are few, so a + /// linear dedup beats hashing `(child, parent)` pairs. + fn connect(&mut self, child: Option, parent: gix::ObjectId) { + if let Some(child) = child { + let parents = self.parents_followed.entry(child).or_default(); + if !parents.contains(&parent) { + parents.push(parent); + } + } + } + /// `flags(x) |= add` for `start` and every stored ANCESTOR reached via followed edges, each + /// visited once. Returns the visited commits when `needs_visited` (for the caller's + /// leaf-segment computation). + fn propagate_flags_downward( + &mut self, + add: CommitFlags, + start: gix::ObjectId, + needs_visited: bool, + ) -> Option> { + let mut visited = gix::hashtable::HashSet::default(); + let mut stack = vec![start]; + while let Some(id) = stack.pop() { + if !visited.insert(id) { + continue; + } + self.flags_or(id, add); + stack.extend( + self.parents_followed + .get(&id) + .into_iter() + .flatten() + .copied(), + ); + } + needs_visited.then_some(visited) + } +} + +/// The walk's own segment bookkeeping โ€” which segment each collected commit belongs to. Three +/// decisions read it: which queued tips inherit goals at a re-encounter (leaf SEGMENTS, not +/// leaf commits), the entrypoint owner's name and first-commit flags (the integrated-cutoff +/// gate), and the workspace-ref attachment. Boundaries: every seed is a segment; merge parents +/// start new ones; a continuation splits off at an unambiguous branch name or a merge; a +/// mid-segment re-encounter splits the tail. + +#[derive(Default)] +struct Segs { + names: Vec>, + commits: Vec>, + of: HashMap, +} + +impl Segs { + fn add(&mut self, seg: &SeedSegment) -> usize { + let ix = self.names.len(); + self.names.push(seg.ref_info.clone()); + self.commits.push(Vec::new()); + ix + } + /// Seed-table entries created since the last call become segments; `seed_seg` maps seed โ†’ seg. + fn sync_seeds(&mut self, seed_segments: &[SeedSegment], seed_seg: &mut Vec) { + while seed_seg.len() < seed_segments.len() { + let seg = self.add(&seed_segments[seed_seg.len()]); + seed_seg.push(seg); + } + } + fn push_commit(&mut self, seg: usize, id: gix::ObjectId) { + self.commits[seg].push(id); + self.of.insert(id, seg); + } + /// `commits[pos..]` become their own (anonymous) segment. + fn split_off_tail(&mut self, seg: usize, pos: usize) { + let tail = self.commits[seg].split_off(pos); + let new_seg = self.names.len(); + self.names.push(None); + for id in &tail { + self.of.insert(*id, new_seg); + } + self.commits.push(tail); + } + /// The segment a queue item collects into โ€” seed_segments into their seed segment, parents into the + /// queuing commit's CURRENT segment (splits and swaps are picked up live, which is what the + /// walk's queue-rewriting on split achieves). + fn landing(&self, instr: &Instruction, seed_seg: &[usize]) -> Option { + instr + .seed + .map(|ix| seed_seg[ix]) + .or_else(|| instr.queued_by.map(|qb| self.of[&qb])) + } + /// Does any commit of this segment have a followed edge into a different segment below it? + fn has_outgoing( + &self, + seg: usize, + parents_followed: &HashMap>, + ) -> bool { + self.commits[seg].iter().any(|c| { + parents_followed + .get(c) + .is_some_and(|ps| ps.iter().any(|p| self.of.get(p).is_some_and(|&s| s != seg))) + }) + } +} + +/// THE commit traversal: a queue-driven walk from the seeds under the workspace limits, +/// storing commits directly. +#[allow(clippy::too_many_arguments)] +#[tracing::instrument(name = "walker::traverse", level = "trace", skip_all, err(Debug))] +pub(crate) fn traverse( + repo: &OverlayRepo<'_>, + seeds: Vec, + meta: &OverlayMetadata<'_, T>, + project_meta: but_core::ref_metadata::ProjectMeta, + options: Options, + ref_name_override: Option, +) -> anyhow::Result { + let entrypoint_seed = super::validate_explicit_seeds(repo, &seeds, ref_name_override.as_ref())?; + let entrypoint_id = entrypoint_seed.id; + let detach_entrypoint = entrypoint_seed.is_detached; + let ref_name = if detach_entrypoint { + None + } else { + ref_name_override.or_else(|| entrypoint_seed.ref_name.clone()) + }; + + let Options { + collect_tags, + extra_target_commit_id, + commits_limit_hint: limit, + commits_limit_recharge_location: mut max_commits_recharge_location, + hard_limit, + } = options; + let max_limit = Limit::new(limit); + if ref_name + .as_ref() + .is_some_and(|name| name.category() == Some(Category::RemoteBranch)) + { + anyhow::bail!("Cannot currently handle remotes as start position"); + } + let commit_graph = repo.commit_graph_if_enabled()?; + let shallow_commits = repo.shallow_commits()?; + let mut buf = Vec::new(); + + let configured_remote_tracking_branches = + super::remotes::configured_remote_tracking_branches(repo)?; + let initial_seeds = + super::assemble_initial_seeds(repo, seeds, &project_meta, extra_target_commit_id); + let mut refs_by_id = repo.collect_ref_mapping_by_prefix( + ["refs/heads/", "refs/remotes/"] + .into_iter() + .chain(if collect_tags { + Some("refs/tags/") + } else { + None + }), + &initial_seeds + .workspace_ref_names + .iter() + .map(|ref_name| ref_name.as_ref()) + .collect::>(), + )?; + let worktree_by_branch = repo.worktree_branches(ref_name.as_ref().map(|r| r.as_ref()))?; + + let mut goals = Goals::default(); + let tip_flags = CommitFlags::NotInRemote + | goals + .flag_for(entrypoint_id) + .expect("we have more than one bitflag for this"); + + let mut store = Store::default(); + let mut seen = gix::hashtable::HashSet::default(); + let mut next: Queue = Queue::new_with_limit(hard_limit); + let mut seed_segments: Vec = Vec::new(); + let mut segs = Segs::default(); + let mut seed_seg: Vec = Vec::new(); + // The seed whose segment `graph.entrypoint` points at โ€” its (evolving) first commit gates + // `prune_integrated_tips`. + let mut ep_seed: Option = None; + + let target_limit = max_limit + .with_indirect_goal(entrypoint_id, &mut goals) + .without_allowance(); + + queue_initial_seeds( + &mut next, + &mut seed_segments, + &mut ep_seed, + &initial_seeds, + entrypoint_id, + tip_flags, + max_limit, + target_limit, + &mut goals, + commit_graph.as_ref(), + repo, + meta, + &refs_by_id, + &worktree_by_branch, + &mut buf, + )?; + prioritize_and_ensure_ws_ownership( + &mut next, + &mut seed_segments, + &mut ep_seed, + (initial_seeds.workspace_seeds.clone(), repo, meta), + &worktree_by_branch, + )?; + + max_commits_recharge_location.sort(); + let mut points_of_interest_to_traverse_first = next.inner.len(); + while let Some((info, mut propagated_flags, instr, mut limit)) = next.pop_front() { + points_of_interest_to_traverse_first = + points_of_interest_to_traverse_first.saturating_sub(1); + + let id = info.id; + segs.sync_seeds(&seed_segments, &mut seed_seg); + if max_commits_recharge_location.binary_search(&id).is_ok() { + limit.set_but_keep_goal(max_limit); + } + // Pick up flags propagated onto the queuing commit since this item was queued. + let src_flags = instr + .queued_by + .map(|qid| store.flags(qid)) + .unwrap_or_default(); + propagated_flags |= src_flags; + let is_shallow_boundary = shallow_commits + .as_ref() + .is_some_and(|boundary| boundary.binary_search(&id).is_ok()); + if is_shallow_boundary { + propagated_flags |= CommitFlags::ShallowBoundary; + } + + if seen.contains(&id) { + re_encounter( + &mut store, + &mut segs, + &mut next, + &seed_seg, + id, + instr.queued_by, + propagated_flags, + limit, + ); + // NB: the walk `continue`s straight past prune and sort on this path. + continue; + } + seen.insert(id); + store.connect(instr.queued_by, id); + + let seg_for_id = segment_for_commit( + &mut segs, + &instr, + &seed_seg, + &info, + id, + meta, + &refs_by_id, + &worktree_by_branch, + )?; + segs.push_commit(seg_for_id, id); + + let refs_at_commit_before_removal = refs_by_id.remove(&id).unwrap_or_default(); + let RemoteQueueOutcome { + items_to_queue_later: remote_items_to_queue_later, + maybe_make_id_a_goal_so_remote_can_find_local, + limit_to_let_local_find_remote, + } = try_queue_remote_tracking_branches( + repo, + &refs_at_commit_before_removal, + &mut seed_segments, + &mut ep_seed, + &next, + &initial_seeds.symbolic_remote_names, + &configured_remote_tracking_branches, + &initial_seeds.target_refs, + meta, + id, + limit, + &mut goals, + &worktree_by_branch, + commit_graph.as_ref(), + repo.for_find_only(), + &mut buf, + )?; + + let propagated_flags = propagated_flags | maybe_make_id_a_goal_so_remote_can_find_local; + queue_parents( + &mut next, + &info.parent_ids, + propagated_flags, + id, + limit.additional_goal(limit_to_let_local_find_remote), + is_shallow_boundary, + commit_graph.as_ref(), + repo.for_find_only(), + &mut buf, + )?; + + // Store the commit: flags inherit the queuing commit's CURRENT flags (`src_flags` + // above); refs are ALL the refs at the commit, canonically sorted by name. + let mut refs: Vec = refs_at_commit_before_removal + .into_iter() + .map(|rn| crate::RefInfo::from_ref(rn, id, &worktree_by_branch)) + .collect(); + refs.sort_by(|a, b| a.ref_name.cmp(&b.ref_name)); + store.push(Commit { + id, + parent_ids: info.parent_ids.iter().copied().collect(), + flags: propagated_flags, + refs, + }); + + for item in remote_items_to_queue_later { + if next.push_back_exhausted(item) { + break; + } + } + let ep_first_flags = entrypoint_first_commit_flags(&store, &segs, &seed_seg, ep_seed); + prune_integrated_tips(&mut next, ep_first_flags); + if points_of_interest_to_traverse_first == 0 { + next.sort(); + } + } + + // The ref of the segment OWNING the entrypoint commit โ€” `None` once ownership moved to an + // anonymous stand-in or an ambiguous split. Only the walk can know this: ownership evolves + // DURING traversal, so the build cannot re-derive it afterwards. It is the walk's only + // naming output besides the ws-ref attachment below; all other segment naming lives in + // the build. + let entrypoint_ref = segs + .of + .get(&entrypoint_id) + .and_then(|&s| segs.names[s].as_ref().map(|ri| ri.ref_name.clone())); + attach_workspace_refs(&mut store, &segs, &initial_seeds.workspace_ref_names); + Ok(WalkOutcome { + commits: store.commits, + by_id: store.by_id, + parents_followed: store.parents_followed, + entrypoint: Some(entrypoint_id), + entrypoint_ref, + hard_limit_hit: next.hard_limit_hit(), + seeds: initial_seeds.seeds, + }) +} + +/// Queue the initial seeds, with segments replaced by throwaway seed records (created by +/// the SAME `branch_segment_from_name_and_meta`). +#[allow(clippy::too_many_arguments)] +fn queue_initial_seeds( + next: &mut Queue, + seed_segments: &mut Vec, + ep_seed: &mut Option, + initial_seeds: &InitialSeeds, + entrypoint: gix::ObjectId, + entrypoint_flags: CommitFlags, + max_limit: Limit, + target_limit: Limit, + goals: &mut Goals, + commit_graph: Option<&gix::commitgraph::Graph>, + repo: &OverlayRepo<'_>, + meta: &OverlayMetadata<'_, T>, + refs_by_id: &super::utils::RefsById, + worktree_by_branch: &WorktreeByBranch, + buf: &mut Vec, +) -> anyhow::Result<()> { + let mut pairing = PendingTargets { + target_limit, + commit_graph, + repo, + local_goals: Default::default(), + parked: Default::default(), + }; + + for seed in &initial_seeds.seeds { + match &seed.role { + SeedRole::WorkspaceStackBranch { .. } if next.iter().any(|t| t.0.id == seed.id) => { + next.add_goal_to(seed.id, goals.flag_for(entrypoint).unwrap_or_default()); + continue; + } + SeedRole::TargetRemote + if seed + .is_auxiliary_integrated_seed(&initial_seeds.auxiliary_integrated_seed_ids) + && next.iter().any(|(info, _, _, _)| info.id == seed.id) => + { + continue; + } + _ => {} + } + + let segment = mint_seed_segment(seed, meta, refs_by_id, worktree_by_branch)?; + let seg = seed_segments.len(); + seed_segments.push(segment); + + if let SeedRole::TargetRemote = &seed.role { + let pending = PendingSeed { + id: seed.id, + seed: seg, + queue_front: super::queue_should_frontload_seed( + seed, + initial_seeds.frontload_workspace_related_seeds, + &initial_seeds.auxiliary_integrated_seed_ids, + ), + }; + let target_ref = seed + .ref_name + .as_ref() + .filter(|ref_name| { + initial_seeds + .target_local_links + .local_by_target + .contains_key(*ref_name) + }) + .cloned(); + pairing.park_or_queue(next, target_ref, pending, buf)?; + continue; + } + + let (flags, limit) = match &seed.role { + SeedRole::Reachable if seed.is_entrypoint => { + *ep_seed = Some(seg); + (entrypoint_flags, max_limit) + } + SeedRole::Reachable => { + super::reachable_seed_flags_and_limit(seed.id, entrypoint, max_limit, goals) + } + SeedRole::TargetRemote => unreachable!("handled above"), + SeedRole::Workspace => { + if seed.is_entrypoint && ep_seed.is_none() { + *ep_seed = Some(seg); + } + let extra_flags = if seed.is_entrypoint { + entrypoint_flags + } else { + CommitFlags::empty() + }; + let limit = if seed.is_entrypoint { + max_limit + } else { + max_limit.with_indirect_goal(entrypoint, goals) + }; + ( + CommitFlags::InWorkspace | CommitFlags::NotInRemote | extra_flags, + limit, + ) + } + SeedRole::TargetLocal { local_ref_name } => { + let goal = goals.flag_for(seed.id).unwrap_or_default(); + if let Some(target_ref) = initial_seeds + .target_local_links + .target_by_local + .get(local_ref_name) + { + pairing.local_goals.insert(target_ref.clone(), goal); + } + next.add_goal_to(entrypoint, goal); + (CommitFlags::NotInRemote | goal, target_limit) + } + SeedRole::WorkspaceStackBranch { .. } => ( + CommitFlags::NotInRemote, + max_limit.with_indirect_goal(entrypoint, goals), + ), + }; + let item = seed_item( + commit_graph, + repo.for_find_only(), + seed.id, + seg, + flags, + limit, + buf, + )?; + let paired_target_ref = match &seed.role { + SeedRole::TargetLocal { local_ref_name } => initial_seeds + .target_local_links + .target_by_local + .get(local_ref_name) + .cloned(), + _ => None, + }; + // A parked target pointing at the LOCAL'S OWN commit queues before the local... + if let Some(target_ref) = &paired_target_ref + && pairing + .parked + .get(target_ref) + .is_some_and(|pending| pending.id == seed.id) + { + pairing.release(next, target_ref, buf)?; + } + if super::queue_should_frontload_seed( + seed, + initial_seeds.frontload_workspace_related_seeds, + &initial_seeds.auxiliary_integrated_seed_ids, + ) { + _ = next.push_front_exhausted(item); + } else { + _ = next.push_back_exhausted(item); + } + // ...any other parked target follows it. + if let Some(target_ref) = &paired_target_ref { + pairing.release(next, target_ref, buf)?; + } + } + Ok(()) +} + +/// Mint the queue item for seed `seed`'s tip `id`: looked up fresh, queued by nobody, +/// never starting a new segment. +fn seed_item( + cache: Option<&gix::commitgraph::Graph>, + objects: &impl gix::objs::Find, + id: gix::ObjectId, + seed: usize, + flags: CommitFlags, + limit: Limit, + buf: &mut Vec, +) -> anyhow::Result { + Ok(( + find(cache, objects, id, buf)?, + flags, + Instruction { + queued_by: None, + seed: Some(seed), + new_segment: false, + }, + limit, + )) +} + +/// A target seed parked until it can queue: where it points, its seed-table parent number, and +/// which queue end it wants. +struct PendingSeed { + id: gix::ObjectId, + seed: usize, + queue_front: bool, +} + +/// The target/local pairing ledger of the initial-seed queueing: a target seed must +/// queue with its LOCAL partner's goal flag in its limit (so the target's walk can find +/// the local), so a target arriving before its local PARKS here and is released when +/// the local passes by โ€” before the local's own queue item when both point at the same +/// commit, right after it otherwise. +struct PendingTargets<'a, 'repo> { + target_limit: Limit, + commit_graph: Option<&'a gix::commitgraph::Graph>, + repo: &'a OverlayRepo<'repo>, + /// Per target ref: its local partner's goal flag, recorded when the local queues. + local_goals: std::collections::BTreeMap, + /// Targets parked until their local partner passes by. + parked: std::collections::BTreeMap, +} + +impl PendingTargets<'_, '_> { + /// Queue `pending` as an integrated tip whose limit carries `local_goal`. + fn queue( + &self, + next: &mut Queue, + pending: PendingSeed, + local_goal: CommitFlags, + buf: &mut Vec, + ) -> anyhow::Result<()> { + let item = seed_item( + self.commit_graph, + self.repo.for_find_only(), + pending.id, + pending.seed, + CommitFlags::Integrated, + self.target_limit.additional_goal(local_goal), + buf, + )?; + if pending.queue_front { + _ = next.push_front_exhausted(item); + } else { + _ = next.push_back_exhausted(item); + } + Ok(()) + } + + /// A target seed arrives: queue it right away when it has no local partner (no goal + /// to wait for) or when the partner's goal is already on the ledger โ€” park it + /// otherwise. + fn park_or_queue( + &mut self, + next: &mut Queue, + target_ref: Option, + pending: PendingSeed, + buf: &mut Vec, + ) -> anyhow::Result<()> { + let Some(target_ref) = target_ref else { + return self.queue(next, pending, CommitFlags::empty(), buf); + }; + match self.local_goals.get(&target_ref).copied() { + Some(local_goal) => self.queue(next, pending, local_goal, buf), + None => { + self.parked.insert(target_ref, pending); + Ok(()) + } + } + } + + /// Release the target parked for `target_ref`, if any, with the ledger's goal flag. + fn release( + &mut self, + next: &mut Queue, + target_ref: &gix::refs::FullName, + buf: &mut Vec, + ) -> anyhow::Result<()> { + let Some(pending) = self.parked.remove(target_ref) else { + return Ok(()); + }; + let local_goal = self + .local_goals + .get(target_ref) + .copied() + .unwrap_or(CommitFlags::empty()); + self.queue(next, pending, local_goal, buf) + } +} + +/// Mint the seed's table record via the shared namer; a workspace stack branch whose +/// desired name is REMOTE and resolved to nothing still records that remote name. +fn mint_seed_segment( + seed: &Seed, + meta: &OverlayMetadata<'_, T>, + refs_by_id: &super::utils::RefsById, + worktree_by_branch: &WorktreeByBranch, +) -> anyhow::Result { + let mut segment = branch_segment_from_name_and_meta( + seed.ref_name + .clone() + .map(|ref_name| (ref_name, seed.metadata.clone())), + meta, + Some((refs_by_id, seed.id)), + worktree_by_branch, + )?; + if let SeedRole::WorkspaceStackBranch { desired_ref_name } = &seed.role { + let is_remote = desired_ref_name + .category() + .is_some_and(|c| c == Category::RemoteBranch); + if segment.ref_info.is_none() && is_remote { + segment.ref_info = Some(crate::RefInfo::from_ref( + desired_ref_name.clone(), + seed.id, + worktree_by_branch, + )); + } + } + Ok(segment) +} +/// Swap the first two queued items on `ws_tip` when the first one's workspace-seed-ness +/// equals `swap_when_first_is_ws`; no-op with fewer than two items on the tip. +fn swap_first_two_on_tip_if( + next: &mut Queue, + seed_segments: &[SeedSegment], + ws_tip: gix::ObjectId, + swap_when_first_is_ws: bool, +) { + let mut with_ws_tip = next + .iter() + .enumerate() + .filter_map(|(idx, (info, _, instr, _))| (info.id == ws_tip).then_some((idx, instr.seed))); + let (Some(first), Some(second)) = (with_ws_tip.next(), with_ws_tip.next()) else { + return; + }; + drop(with_ws_tip); + let first_is_ws = first + .1 + .is_some_and(|ix| seed_segments[ix].workspace_metadata().is_some()); + if first_is_ws == swap_when_first_is_ws { + next.inner.swap(first.0, second.0); + } +} + +/// Prioritize the initial seeds and assure workspace-commit ownership: the initial-queue +/// sort and swap logic, run against the seed table. +fn prioritize_and_ensure_ws_ownership( + next: &mut Queue, + seed_segments: &mut Vec, + ep_seed: &mut Option, + (ws_tips, repo, meta): ( + Vec, + &OverlayRepo<'_>, + &OverlayMetadata<'_, T>, + ), + worktree_by_branch: &WorktreeByBranch, +) -> anyhow::Result<()> { + #[derive(Ord, PartialOrd, PartialEq, Eq)] + enum Kind { + Local, + Workspace, + NonLocal, + } + { + let seed_segments = &*seed_segments; + next.inner + .make_contiguous() + .sort_by_key(|(_info, _flags, instr, _limit)| { + instr + .seed + .and_then(|ix| seed_segments[ix].ref_name()) + .map(|rn| match rn.category() { + Some(Category::LocalBranch) => { + if but_core::is_workspace_ref_name(rn) { + Kind::Workspace + } else { + Kind::Local + } + } + _ => Kind::NonLocal, + }) + }); + } + + for ws_tip in ws_tips { + if crate::workspace::commit::is_managed_workspace_by_message( + repo.find_commit(ws_tip)?.message_raw()?, + ) { + // A non-workspace seed arriving first would own the managed commit โ€” the + // workspace seed must go second-to-first. + swap_first_two_on_tip_if(next, seed_segments, ws_tip, false); + } else if next + .iter() + .filter(|(info, ..)| info.id == ws_tip) + .take(2) + .count() + >= 2 + { + // Unmanaged commit: the WORKSPACE seed must NOT own it โ€” swap it back. + swap_first_two_on_tip_if(next, seed_segments, ws_tip, true); + } else { + // Single tip on an unmanaged workspace commit with workspace metadata: an anonymous + // stand-in owns the commit first (a duplicate front item; the real item then takes + // the re-encounter path). + let (info, flags, _instr, limit) = next + .iter() + .find(|t| t.0.id == ws_tip) + .cloned() + .expect("each ws-tip has one entry on queue"); + let anon = branch_segment_from_name_and_meta(None, meta, None, worktree_by_branch)?; + let seed = seed_segments.len(); + seed_segments.push(anon); + if ep_seed.is_none() { + *ep_seed = Some(seed); + } + _ = next.push_front_exhausted(( + info, + flags, + Instruction { + queued_by: None, + seed: Some(seed), + new_segment: false, + }, + limit, + )); + } + } + Ok(()) +} +/// Re-encounter of an already-collected commit: record the edge, run the +/// segment-ownership choreography (mid-segment split), merge flags, propagate them +/// downward, and adjust queued tips' goals and limits. +#[allow(clippy::too_many_arguments)] +fn re_encounter( + store: &mut Store, + segs: &mut Segs, + next: &mut Queue, + seed_seg: &[usize], + id: gix::ObjectId, + queued_by: Option, + propagated_flags: CommitFlags, + limit: Limit, +) { + store.connect(queued_by, id); + let bottom_seg = segs.of[&id]; + let pos = segs.commits[bottom_seg] + .iter() + .position(|&c| c == id) + .expect("owning segment lists its commits"); + if pos != 0 { + segs.split_off_tail(bottom_seg, pos); + } + let existing_flags = store.flags(id); + let new_flags = propagated_flags | existing_flags; + let needs_leafs = !limit.goal_reached(); + let visited = if new_flags != existing_flags + || (needs_leafs + && next + .iter() + .any(|(_, _, _, tip_limit)| !tip_limit.goal_flags().contains(limit.goal_flags()))) + { + store.propagate_flags_downward(new_flags, id, needs_leafs) + } else { + None + }; + if let Some(visited) = visited { + // Tips scheduled to land in one of the propagation's leaf segments โ€” visited, + // no outgoing edges โ€” inherit the goal. + let leaf_segs: std::collections::BTreeSet = visited + .iter() + .map(|c| segs.of[c]) + .collect::>() + .into_iter() + .filter(|&s| !segs.has_outgoing(s, &store.parents_followed)) + .collect(); + let goal_flags = limit.goal_flags(); + for (_, _, item_instr, item_limit) in next.iter_mut() { + if segs + .landing(item_instr, seed_seg) + .is_some_and(|s| leaf_segs.contains(&s)) + { + *item_limit = item_limit.additional_goal(goal_flags); + } + } + } + // Tips that saw this commit extend their limit if that would extend it. + let bottom_commit_goals = Limit::new(None) + .additional_goal(store.flags(id)) + .goal_flags(); + for queued_tip_limit in next + .iter_mut() + .filter_map(|(_, _, _, l)| l.goal_flags().intersects(bottom_commit_goals).then_some(l)) + { + queued_tip_limit.adjust_limit_if_bigger(limit); + } +} + +/// Which segment does this commit join? Seeds own their segment; a merge parent starts a +/// new one; a continuation splits off when the commit carries an unambiguous branch name +/// or is itself a merge โ€” decided BEFORE the refs are consumed. +#[allow(clippy::too_many_arguments)] +fn segment_for_commit( + segs: &mut Segs, + instr: &Instruction, + seed_seg: &[usize], + info: &super::utils::TraverseInfo, + id: gix::ObjectId, + meta: &OverlayMetadata<'_, T>, + refs_by_id: &super::utils::RefsById, + worktree_by_branch: &WorktreeByBranch, +) -> anyhow::Result { + Ok(if let Some(ix) = instr.seed { + seed_seg[ix] + } else if instr.new_segment { + let seg = branch_segment_from_name_and_meta( + None, + meta, + Some((refs_by_id, id)), + worktree_by_branch, + )?; + segs.add(&seg) + } else { + let src_seg = segs.of[&instr + .queued_by + .expect("non-seed items have a queuing commit")]; + let maybe_name = + super::utils::disambiguate_refs_by_branch_metadata_with_lookup((refs_by_id, id), meta); + if !segs.commits[src_seg].is_empty() && (maybe_name.is_some() || info.parent_ids.len() >= 2) + { + let seg = + branch_segment_from_name_and_meta(maybe_name, meta, None, worktree_by_branch)?; + segs.add(&seg) + } else { + src_seg + } + }) +} + +/// Attach each workspace ref to its seed segment's first commit โ€” the ws ref never enters +/// ref collection, so it must come back as data here. This is the ADVANCED-REF presentation +/// contract, not bookkeeping: when the ref points at a commit the walk never reached +/// (advanced outside the workspace, overlay preview), the name still surfaces at the +/// walk-visible position. +fn attach_workspace_refs(store: &mut Store, segs: &Segs, ws_ref_names: &[gix::refs::FullName]) { + for (seg, name) in segs.names.iter().enumerate() { + let (Some(ri), Some(first)) = (name, segs.commits[seg].first()) else { + continue; + }; + if !ws_ref_names.contains(&ri.ref_name) { + continue; + } + let cix = store.by_id[first]; + let refs = &mut store.commits[cix].refs; + if !refs.iter().any(|r| r.ref_name == ri.ref_name) { + refs.push(ri.clone()); + refs.sort_by(|a, b| a.ref_name.cmp(&b.ref_name)); + } + } +} + +/// Queue every parent of `commit` (goals, limits, flag propagation); the payload carries the +/// queuing commit. +#[allow(clippy::too_many_arguments)] +fn queue_parents( + next: &mut Queue, + parent_ids: &[gix::ObjectId], + flags: CommitFlags, + current: gix::ObjectId, + mut limit: Limit, + is_shallow_boundary: bool, + commit_graph: Option<&gix::commitgraph::Graph>, + objects: &impl gix::objs::Find, + buf: &mut Vec, +) -> anyhow::Result { + if is_shallow_boundary { + return Ok(false); + } + if next.is_exhausted() { + return Ok(next.hard_limit_hit()); + } + if limit.is_exhausted_or_decrement(flags, next) { + return Ok(false); + } + let mut queue_is_exhausted = false; + if parent_ids.len() > 1 { + let instr = Instruction { + queued_by: Some(current), + seed: None, + new_segment: true, + }; + let limit_per_parent = limit.per_parent(parent_ids.len()); + for pid in parent_ids.iter() { + let info = find(commit_graph, objects, *pid, buf)?; + queue_is_exhausted = + next.push_back_even_if_exhausted((info, flags, instr, limit_per_parent)); + } + } else if !parent_ids.is_empty() { + let instr = Instruction { + queued_by: Some(current), + seed: None, + new_segment: false, + }; + let info = find(commit_graph, objects, parent_ids[0], buf)?; + queue_is_exhausted |= next.push_back_exhausted((info, flags, instr, limit)); + } + Ok(queue_is_exhausted) +} + +/// The flags of the first commit currently in the entrypoint seed's segment (its contents +/// evolve through swaps and splits), `None` while empty or not yet materialized. +fn entrypoint_first_commit_flags( + store: &Store, + segs: &Segs, + seed_seg: &[usize], + ep_seed: Option, +) -> Option { + let first = *segs.commits[*seed_seg.get(ep_seed?)?].first()?; + Some(store.flags(first)).filter(|f| !f.is_empty()) +} + +/// Drop queued tips that are already integrated; the entrypoint-integrated check gets +/// the entrypoint segment's first-commit flags from [`entrypoint_first_commit_flags`]. +fn prune_integrated_tips(next: &mut Queue, ep_first_flags: Option) { + if next.is_exhausted() { + return; + } + let all_integrated_and_done = next.iter().all(|(_id, flags, _instr, tip_limit)| { + flags.contains(CommitFlags::Integrated) && tip_limit.goal_reached() + }); + if !all_integrated_and_done { + return; + } + if ep_first_flags.is_some_and(|flags| flags.contains(CommitFlags::Integrated)) { + return; + } + next.exhaust(); +} + +/// Queue the remote-tracking branches of newly seen local branches (lookup, goals, limits); +/// the proxy segment becomes a seed record (its name is used for the double-queue check). +#[allow(clippy::too_many_arguments)] +fn try_queue_remote_tracking_branches( + repo: &OverlayRepo<'_>, + refs: &[gix::refs::FullName], + seed_segments: &mut Vec, + ep_seed: &mut Option, + next: &Queue, + target_symbolic_remote_names: &[String], + configured_remote_tracking_branches: &std::collections::BTreeSet, + target_refs: &[gix::refs::FullName], + meta: &OverlayMetadata<'_, T>, + id: gix::ObjectId, + limit: Limit, + goals: &mut Goals, + worktree_by_branch: &WorktreeByBranch, + commit_graph: Option<&gix::commitgraph::Graph>, + objects: &impl gix::objs::Find, + buf: &mut Vec, +) -> anyhow::Result { + let mut goal_flags = CommitFlags::empty(); + let mut limit_flags = CommitFlags::empty(); + let mut queue = Vec::new(); + for rn in refs { + let Some(remote_tracking_branch) = + super::remotes::lookup_remote_tracking_branch_or_deduce_it( + repo, + rn.as_ref(), + target_symbolic_remote_names, + configured_remote_tracking_branches, + )? + else { + continue; + }; + if target_refs.contains(&remote_tracking_branch) { + continue; + } + let Some(remote_tip) = + super::utils::try_refname_to_id(repo, remote_tracking_branch.as_ref())? + else { + continue; + }; + if next.iter().any(|t| { + t.0.id == remote_tip + && t.2 + .seed + .and_then(|ix| seed_segments[ix].ref_name()) + .is_some_and(|sn| sn == remote_tracking_branch.as_ref()) + }) { + continue; + } + let seg = branch_segment_from_name_and_meta( + Some((remote_tracking_branch.clone(), None)), + meta, + None, + worktree_by_branch, + )?; + let seed = seed_segments.len(); + seed_segments.push(seg); + if ep_seed.is_none() { + *ep_seed = Some(seed); + } + + let remote_limit = limit.with_indirect_goal(id, goals); + let self_flags = goals.flag_for(remote_tip).unwrap_or_default(); + limit_flags |= self_flags; + goal_flags |= remote_limit.goal_flags(); + let remote_tip_info = find(commit_graph, objects, remote_tip, buf)?; + queue.push(( + remote_tip_info, + self_flags, + Instruction { + queued_by: None, + seed: Some(seed), + new_segment: false, + }, + remote_limit, + )); + } + Ok(RemoteQueueOutcome { + items_to_queue_later: queue, + maybe_make_id_a_goal_so_remote_can_find_local: goal_flags, + limit_to_let_local_find_remote: limit_flags, + }) +} From 6b0f10d301029cfcf2ae9fa2cc0bfdf25414c943 Mon Sep 17 00:00:00 2001 From: Mattias Granlund Date: Sun, 12 Jul 2026 11:30:05 +0300 Subject: [PATCH 03/11] Cover the walker with workspace traversal tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The walker's spec as tests, ahead of it going live: workspace traversals (managed, ad-hoc, unborn, overlays) rendered as snapshot DAGs, plus a slash-named-remote fixture the old suite never had. - with_workspace.rs is the bulk โ€” one scenario per workspace shape, asserting the full rendered graph - arrives undeclared like the walker itself; the harness picks these up at the switch-over Skim-friendly: all test expectations, no production code. --- .../but-graph/tests/fixtures/slash-remote.sh | 34 + crates/but-graph/tests/graph/walk/overlay.rs | 332 + .../tests/graph/walk/with_workspace.rs | 8547 +++++++++++++++++ .../scenario/two-stacks-second-double.sh | 22 + 4 files changed, 8935 insertions(+) create mode 100755 crates/but-graph/tests/fixtures/slash-remote.sh create mode 100644 crates/but-graph/tests/graph/walk/overlay.rs create mode 100644 crates/but-graph/tests/graph/walk/with_workspace.rs create mode 100644 crates/but/tests/fixtures/scenario/two-stacks-second-double.sh diff --git a/crates/but-graph/tests/fixtures/slash-remote.sh b/crates/but-graph/tests/fixtures/slash-remote.sh new file mode 100755 index 00000000000..ba15b1ade71 --- /dev/null +++ b/crates/but-graph/tests/fixtures/slash-remote.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash + +### General Description + +# A workspace whose target remote has a slash in its name (`special/origin`) โ€” +# remote-name extraction must never assume slash-free remote names. +source "${BASH_SOURCE[0]%/*}/shared.sh" + +git init slash-remote +(cd slash-remote + commit init + git checkout -b A + commit A1 + git checkout main + commit M2 + create_workspace_commit_once main A + + git checkout -b soon-remote-main main~1 + commit RM1 + git checkout gitbutler/workspace + + cat <>.git/config +[remote "special/origin"] + url = ./fake/local/path/which-is-fine-as-we-dont-fetch-or-push + fetch = +refs/heads/*:refs/remotes/special/origin/* + +[branch "main"] + remote = special/origin + merge = refs/heads/main +EOF + + mkdir -p .git/refs/remotes/special/origin + mv .git/refs/heads/soon-remote-main .git/refs/remotes/special/origin/main +) diff --git a/crates/but-graph/tests/graph/walk/overlay.rs b/crates/but-graph/tests/graph/walk/overlay.rs new file mode 100644 index 00000000000..412a6f756c4 --- /dev/null +++ b/crates/but-graph/tests/graph/walk/overlay.rs @@ -0,0 +1,332 @@ +//! Some tests that explicitly test the overlay functionality + +use but_graph::{Workspace, walk::Overlay}; +use but_testsupport::{graph_dag, visualize_commit_graph_all}; +use snapbox::prelude::*; + +use crate::walk::{read_only_in_memory_scenario, standard_options}; + +#[test] +fn drop_and_add_regular_refs() -> anyhow::Result<()> { + let (repo, meta) = read_only_in_memory_scenario("four-diamond")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 8a6c109 (HEAD -> merged) Merge branch 'C' into merged +|\ +| * 7ed512a (C) Merge branch 'D' into C +| |\ +| | * ecb1877 (D) D +| * | 35ee481 C +| |/ +* | 62b409a (A) Merge branch 'B' into A +|\ \ +| * | f16dddf (B) B +| |/ +* / 592abec A +|/ +* 965998b (main) base + +"#]] + .raw() + ); + + let graph = Workspace::from_head( + &repo, + &*meta, + but_core::ref_metadata::ProjectMeta::default(), + standard_options(), + )?; + snapbox::assert_data_eq!( + graph_dag(&graph), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท8a6c109 (โŒ‚) โ–บmerged[๐ŸŒณ] +โ”œโ”€โ•ฎ +* โ”‚ ยท62b409a (โŒ‚) โ–บA +โ”œโ”€โ”€โ”€โ•ฎ +* โ”‚ โ”‚ ยท592abec (โŒ‚) +โ”‚ โ”‚ * ยทf16dddf (โŒ‚) โ–บB +โ”œโ”€โ”€โ”€โ•ฏ +โ”‚ * ยท7ed512a (โŒ‚) โ–บC +โ”‚ โ”œโ”€โ•ฎ +โ”‚ * โ”‚ ยท35ee481 (โŒ‚) +โ”œโ”€โ•ฏ โ”‚ +โ”‚ * ยทecb1877 (โŒ‚) โ–บD +โ”œโ”€โ”€โ”€โ•ฏ +* ๐Ÿยท965998b (โŒ‚) โ–บmain +"#]] + ); + + let to_reference = repo.rev_parse_single("35ee481")?; + + let overlay = Overlay::default() + .with_references([gix::refs::Reference { + name: "refs/heads/new-reference".try_into()?, + target: gix::refs::Target::Object(to_reference.detach()), + peeled: Some(to_reference.detach()), + }]) + .with_dropped_references(["refs/heads/C".try_into()?]); + + let graph = graph.redo(&repo, &*meta, overlay)?; + + snapbox::assert_data_eq!( + graph_dag(&graph), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท8a6c109 (โŒ‚) โ–บmerged[๐ŸŒณ] +โ”œโ”€โ•ฎ +* โ”‚ ยท62b409a (โŒ‚) โ–บA +โ”œโ”€โ”€โ”€โ•ฎ +* โ”‚ โ”‚ ยท592abec (โŒ‚) +โ”‚ โ”‚ * ยทf16dddf (โŒ‚) โ–บB +โ”œโ”€โ”€โ”€โ•ฏ +โ”‚ * ยท7ed512a (โŒ‚) +โ”‚ โ”œโ”€โ•ฎ +โ”‚ * โ”‚ ยท35ee481 (โŒ‚) โ–บnew-reference +โ”œโ”€โ•ฏ โ”‚ +โ”‚ * ยทecb1877 (โŒ‚) โ–บD +โ”œโ”€โ”€โ”€โ•ฏ +* ๐Ÿยท965998b (โŒ‚) โ–บmain +"#]] + ); + + Ok(()) +} + +#[test] +fn drop_head_ref() -> anyhow::Result<()> { + let (repo, meta) = read_only_in_memory_scenario("four-diamond")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 8a6c109 (HEAD -> merged) Merge branch 'C' into merged +|\ +| * 7ed512a (C) Merge branch 'D' into C +| |\ +| | * ecb1877 (D) D +| * | 35ee481 C +| |/ +* | 62b409a (A) Merge branch 'B' into A +|\ \ +| * | f16dddf (B) B +| |/ +* / 592abec A +|/ +* 965998b (main) base + +"#]] + .raw() + ); + + let graph = Workspace::from_head( + &repo, + &*meta, + but_core::ref_metadata::ProjectMeta::default(), + standard_options(), + )?; + snapbox::assert_data_eq!( + graph_dag(&graph), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท8a6c109 (โŒ‚) โ–บmerged[๐ŸŒณ] +โ”œโ”€โ•ฎ +* โ”‚ ยท62b409a (โŒ‚) โ–บA +โ”œโ”€โ”€โ”€โ•ฎ +* โ”‚ โ”‚ ยท592abec (โŒ‚) +โ”‚ โ”‚ * ยทf16dddf (โŒ‚) โ–บB +โ”œโ”€โ”€โ”€โ•ฏ +โ”‚ * ยท7ed512a (โŒ‚) โ–บC +โ”‚ โ”œโ”€โ•ฎ +โ”‚ * โ”‚ ยท35ee481 (โŒ‚) +โ”œโ”€โ•ฏ โ”‚ +โ”‚ * ยทecb1877 (โŒ‚) โ–บD +โ”œโ”€โ”€โ”€โ•ฏ +* ๐Ÿยท965998b (โŒ‚) โ–บmain +"#]] + ); + + let overlay = Overlay::default().with_dropped_references(["refs/heads/merged".try_into()?]); + + let graph = graph.redo(&repo, &*meta, overlay)?; + + snapbox::assert_data_eq!( + graph_dag(&graph), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท8a6c109 (โŒ‚) +โ”œโ”€โ•ฎ +* โ”‚ ยท62b409a (โŒ‚) โ–บA +โ”œโ”€โ”€โ”€โ•ฎ +* โ”‚ โ”‚ ยท592abec (โŒ‚) +โ”‚ โ”‚ * ยทf16dddf (โŒ‚) โ–บB +โ”œโ”€โ”€โ”€โ•ฏ +โ”‚ * ยท7ed512a (โŒ‚) โ–บC +โ”‚ โ”œโ”€โ•ฎ +โ”‚ * โ”‚ ยท35ee481 (โŒ‚) +โ”œโ”€โ•ฏ โ”‚ +โ”‚ * ยทecb1877 (โŒ‚) โ–บD +โ”œโ”€โ”€โ”€โ•ฏ +* ๐Ÿยท965998b (โŒ‚) โ–บmain +"#]] + ); + + Ok(()) +} + +#[test] +fn overriding_references() -> anyhow::Result<()> { + let (repo, meta) = read_only_in_memory_scenario("four-diamond")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 8a6c109 (HEAD -> merged) Merge branch 'C' into merged +|\ +| * 7ed512a (C) Merge branch 'D' into C +| |\ +| | * ecb1877 (D) D +| * | 35ee481 C +| |/ +* | 62b409a (A) Merge branch 'B' into A +|\ \ +| * | f16dddf (B) B +| |/ +* / 592abec A +|/ +* 965998b (main) base + +"#]] + .raw() + ); + + let graph = Workspace::from_head( + &repo, + &*meta, + but_core::ref_metadata::ProjectMeta::default(), + standard_options(), + )?; + snapbox::assert_data_eq!( + graph_dag(&graph), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท8a6c109 (โŒ‚) โ–บmerged[๐ŸŒณ] +โ”œโ”€โ•ฎ +* โ”‚ ยท62b409a (โŒ‚) โ–บA +โ”œโ”€โ”€โ”€โ•ฎ +* โ”‚ โ”‚ ยท592abec (โŒ‚) +โ”‚ โ”‚ * ยทf16dddf (โŒ‚) โ–บB +โ”œโ”€โ”€โ”€โ•ฏ +โ”‚ * ยท7ed512a (โŒ‚) โ–บC +โ”‚ โ”œโ”€โ•ฎ +โ”‚ * โ”‚ ยท35ee481 (โŒ‚) +โ”œโ”€โ•ฏ โ”‚ +โ”‚ * ยทecb1877 (โŒ‚) โ–บD +โ”œโ”€โ”€โ”€โ•ฏ +* ๐Ÿยท965998b (โŒ‚) โ–บmain +"#]] + ); + + let merged_a = repo.rev_parse_single("35ee481")?; + let merged_b = repo.rev_parse_single("592abec")?; + let merged: gix::refs::FullName = "refs/heads/merged".try_into()?; + + // The dropped takes precedence over git or overriding references. + let overlay = Overlay::default() + .with_dropped_references([merged.clone()]) + .with_references([ + gix::refs::Reference { + name: merged.clone(), + target: gix::refs::Target::Object(merged_a.detach()), + peeled: Some(merged_a.detach()), + }, + gix::refs::Reference { + name: merged.clone(), + target: gix::refs::Target::Object(merged_b.detach()), + peeled: Some(merged_b.detach()), + }, + ]); + + let graph = graph.redo(&repo, &*meta, overlay)?; + + snapbox::assert_data_eq!( + graph_dag(&graph), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท8a6c109 (โŒ‚) +โ”œโ”€โ•ฎ +* โ”‚ ยท62b409a (โŒ‚) โ–บA +โ”œโ”€โ”€โ”€โ•ฎ +* โ”‚ โ”‚ ยท592abec (โŒ‚) +โ”‚ โ”‚ * ยทf16dddf (โŒ‚) โ–บB +โ”œโ”€โ”€โ”€โ•ฏ +โ”‚ * ยท7ed512a (โŒ‚) โ–บC +โ”‚ โ”œโ”€โ•ฎ +โ”‚ * โ”‚ ยท35ee481 (โŒ‚) +โ”œโ”€โ•ฏ โ”‚ +โ”‚ * ยทecb1877 (โŒ‚) โ–บD +โ”œโ”€โ”€โ”€โ•ฏ +* ๐Ÿยท965998b (โŒ‚) โ–บmain +"#]] + ); + + // The first overriding reference precedence over git or other overriding references. + let overlay = Overlay::default().with_references([ + gix::refs::Reference { + name: merged.clone(), + target: gix::refs::Target::Object(merged_a.detach()), + peeled: Some(merged_a.detach()), + }, + gix::refs::Reference { + name: merged.clone(), + target: gix::refs::Target::Object(merged_b.detach()), + peeled: Some(merged_b.detach()), + }, + ]); + + let graph = graph.redo(&repo, &*meta, overlay)?; + + snapbox::assert_data_eq!( + graph_dag(&graph), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท8a6c109 (โŒ‚) +โ”œโ”€โ•ฎ +* โ”‚ ยท62b409a (โŒ‚) โ–บA +โ”œโ”€โ”€โ”€โ•ฎ +* โ”‚ โ”‚ ยท592abec (โŒ‚) +โ”‚ โ”‚ * ยทf16dddf (โŒ‚) โ–บB +โ”œโ”€โ”€โ”€โ•ฏ +โ”‚ * ยท7ed512a (โŒ‚) โ–บC +โ”‚ โ”œโ”€โ•ฎ +โ”‚ * โ”‚ ยท35ee481 (โŒ‚) โ–บmerged[๐ŸŒณ] +โ”œโ”€โ•ฏ โ”‚ +โ”‚ * ยทecb1877 (โŒ‚) โ–บD +โ”œโ”€โ”€โ”€โ•ฏ +* ๐Ÿยท965998b (โŒ‚) โ–บmain +"#]] + ); + + // overriding references take precedence over git. + let overlay = Overlay::default().with_references([gix::refs::Reference { + name: merged.clone(), + target: gix::refs::Target::Object(merged_b.detach()), + peeled: Some(merged_b.detach()), + }]); + + let graph = graph.redo(&repo, &*meta, overlay)?; + + snapbox::assert_data_eq!( + graph_dag(&graph), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท8a6c109 (โŒ‚) +โ”œโ”€โ•ฎ +* โ”‚ ยท62b409a (โŒ‚) โ–บA +โ”œโ”€โ”€โ”€โ•ฎ +* โ”‚ โ”‚ ยท592abec (โŒ‚) โ–บmerged[๐ŸŒณ] +โ”‚ โ”‚ * ยทf16dddf (โŒ‚) โ–บB +โ”œโ”€โ”€โ”€โ•ฏ +โ”‚ * ยท7ed512a (โŒ‚) โ–บC +โ”‚ โ”œโ”€โ•ฎ +โ”‚ * โ”‚ ยท35ee481 (โŒ‚) +โ”œโ”€โ•ฏ โ”‚ +โ”‚ * ยทecb1877 (โŒ‚) โ–บD +โ”œโ”€โ”€โ”€โ•ฏ +* ๐Ÿยท965998b (โŒ‚) โ–บmain +"#]] + ); + + Ok(()) +} diff --git a/crates/but-graph/tests/graph/walk/with_workspace.rs b/crates/but-graph/tests/graph/walk/with_workspace.rs new file mode 100644 index 00000000000..a4e579a5f3f --- /dev/null +++ b/crates/but-graph/tests/graph/walk/with_workspace.rs @@ -0,0 +1,8547 @@ +use but_core::{ + RefMetadata, WORKSPACE_REF_NAME, + ref_metadata::{ + ProjectMeta, StackId, WorkspaceCommitRelation, WorkspaceStack, WorkspaceStackBranch, + }, +}; +use but_graph::{ + SegmentMetadata, Workspace, + walk::{Overlay, Seed, SeedRole}, +}; +use but_testsupport::{ + InMemoryRefMetadata, graph_dag, graph_workspace, visualize_commit_graph_all, +}; +use snapbox::prelude::*; + +use crate::walk::{ + StackState, add_stack_with_segments, add_workspace, id_at, id_by_rev, + read_only_in_memory_scenario, standard_options, + utils::{ + add_stack, add_workspace_with_target, add_workspace_without_target, + named_read_only_in_memory_scenario, remove_target, standard_options_with_extra_target, + }, +}; + +fn project_meta(meta: &impl RefMetadata) -> ProjectMeta { + meta.workspace(WORKSPACE_REF_NAME.try_into().expect("valid workspace ref")) + .map(|workspace| workspace.project_meta()) + .unwrap_or_default() +} + +#[test] +fn workspace_with_stack_and_local_target() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/local-target-and-stack")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 59a427f (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|\ +| * a62b0de (A) A2 +| * 120a217 A1 +* | 0a415d8 (main) M3 +| | * 1f5c47b (origin/main) RM1 +| |/ +|/| +* | 73ba99d M2 +|/ +* fafd9d0 init + +"#]] + .raw() + ); + + add_workspace(&mut meta); + let ws = Workspace::from_head( + &repo, + &*meta, + but_core::ref_metadata::ProjectMeta::default(), + standard_options(), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท59a427f (โŒ‚|๐Ÿ˜) +โ”œโ”€โ•ฎ +* โ”‚ ยท0a415d8 (โŒ‚|๐Ÿ˜) โ–บmain <> origin/main +โ”‚ * ยทa62b0de (โŒ‚|๐Ÿ˜) โ–บA +โ”‚ * ยท120a217 (โŒ‚|๐Ÿ˜) +โ”‚ โ”‚ * ๐ŸŸฃ1f5c47b โ–บorigin/main +โ”œโ”€โ”€โ”€โ•ฏ +* โ”‚ ยท73ba99d (โŒ‚|๐Ÿ˜) +โ”œโ”€โ•ฏ +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) +layout: + materialized parents: 59a427f: 0a415d8 a62b0de +"#]] + ); + + let managed_id = ws + .managed_entrypoint_commit_id(&repo)? + .expect("this is managed workspace commit"); + snapbox::assert_data_eq!( + ws.commit_graph() + .node(managed_id) + .expect("managed commit is in the graph") + .to_debug(), + snapbox::str![[r#" +Commit(59a427f, โŒ‚|๐Ÿ˜โ–บgitbutler/workspace[๐ŸŒณ]) + +"#]] + ); + + // It's perfectly valid to have the local tracking branch of our target in the workspace, + // and the low-bound computation works as well. + let ws = &ws; + snapbox::assert_data_eq!( + graph_workspace(ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on fafd9d0 +โ”œโ”€โ”€ โ‰ก:main <> origin/mainโ‡ก1โ‡ฃ1 on fafd9d0 +โ”‚ โ””โ”€โ”€ :main <> origin/mainโ‡ก1โ‡ฃ1 +โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ1f5c47b +โ”‚ โ”œโ”€โ”€ ยท0a415d8 (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ โ„๏ธ73ba99d (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก:A on fafd9d0 + โ””โ”€โ”€ :A + โ”œโ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) + +"#]] + ); + + Ok(()) +} + +#[test] +fn workspace_with_only_local_target() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/local-contained-and-target-ahead")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* e5e2623 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +| * cb54dca (origin/main) RM1 +|/ +* 0a415d8 (main) M3 +* 73ba99d M2 +* fafd9d0 init + +"#]] + ); + + add_workspace(&mut meta); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทe5e2623 (โŒ‚|๐Ÿ˜) +โ”‚ * ๐ŸŸฃcb54dca (โœ“) โ–บorigin/main +โ”œโ”€โ•ฏ +* ยท0a415d8 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain <> origin/main +* ยท73ba99d (โŒ‚|๐Ÿ˜|โœ“) +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) +layout: + materialized parents: e5e2623: 0a415d8 +"#]] + ); + + let ws = &ws; + // It's notable how the local tracking branch of our target (origin/main) is ignored, it's not part of our workspace, + // but acts as base. + snapbox::assert_data_eq!( + graph_workspace(ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 0a415d8 + +"#]] + ); + + Ok(()) +} + +#[test] +fn reproduce_11483() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/reproduce-11483")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 3562fcd (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|\ +| * 7236012 (A) A +* | 68c8a9d (B) B +|/ +* 3183e43 (origin/main, main, below) M1 + +"#]] + .raw() + ); + + add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &[]); + add_stack_with_segments(&mut meta, 2, "B", StackState::InWorkspace, &["below"]); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + let ws = &ws; + snapbox::assert_data_eq!( + graph_workspace(ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ”‚ โ””โ”€โ”€ ยท68c8a9d (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:below +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท7236012 (๐Ÿ˜๏ธ) + +"#]] + ); + + meta.data_mut().branches.clear(); + add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &["below"]); + add_stack_with_segments(&mut meta, 2, "B", StackState::InWorkspace, &[]); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {2} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยท68c8a9d (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {1} + โ”œโ”€โ”€ ๐Ÿ“™:A + โ”‚ โ””โ”€โ”€ ยท7236012 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ๐Ÿ“™:below + +"#]] + ); + + Ok(()) +} + +#[test] +fn workspace_projection_with_advanced_stack_tip() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/advanced-stack-tip-outside-workspace")?; + add_stack_with_segments(&mut meta, 1, "B", StackState::InWorkspace, &["A"]); + + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* cc0bf57 (B) B-outside +| * 2076060 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|/ +* d69fe94 B +* 09d8e52 (A) A +* 85efbe4 (origin/main, main) M + +"#]] + ); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ยทcc0bf57 (โŒ‚) โ–บB +โ”‚ * ๐Ÿ‘‰ยท2076060 (โŒ‚|๐Ÿ˜) +โ”œโ”€โ•ฏ +* ยทd69fe94 (โŒ‚|๐Ÿ˜) +* ยท09d8e52 (โŒ‚|๐Ÿ˜) โ–บA +* ๐Ÿยท85efbe4 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บorigin/main <> origin/main +layout: + materialized parents: 2076060: d69fe94 + empty chain anchors: 09d8e52^ +"#]] + ); + let ws = &ws; + snapbox::assert_data_eq!( + graph_workspace(ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {1} + โ”œโ”€โ”€ ๐Ÿ“™:B + โ”‚ โ”œโ”€โ”€ ยทcc0bf57* + โ”‚ โ””โ”€โ”€ ยทd69fe94 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) + +"#]] + ); + + Ok(()) +} + +#[test] +fn no_overzealous_stacks_due_to_workspace_metadata() -> anyhow::Result<()> { + // NOTE: Was supposed to reproduce #11459, but it found another issue instead. + let (repo, mut meta) = read_only_in_memory_scenario("ws/reproduce-11459")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 12102a6 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|\ +| * 0b203b5 (X) X2 +| * 4840f3b (origin/X) X1 +* | 835086d (three, four) W2 +* | ff310d3 W1 +| | * 5e9d772 (origin/two) T1 +| |/ +|/| +* | a821094 (origin/main, two, remote, one, main, feat-2) M3 +* | bce0c5e M2 +|/ +* 3183e43 (A) M1 + +"#]] + .raw() + ); + + add_stack_with_segments(&mut meta, 1, "X", StackState::InWorkspace, &[]); + add_stack_with_segments(&mut meta, 2, "feat-2", StackState::InWorkspace, &[]); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก:anon: on a821094 {2} +โ”‚ โ””โ”€โ”€ :anon: +โ”‚ โ”œโ”€โ”€ ยท835086d (๐Ÿ˜๏ธ) โ–บfour, โ–บthree +โ”‚ โ””โ”€โ”€ ยทff310d3 (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:X <> origin/Xโ‡ก1 on 3183e43 {1} + โ””โ”€โ”€ ๐Ÿ“™:X <> origin/Xโ‡ก1 + โ”œโ”€โ”€ ยท0b203b5 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ โ„๏ธ4840f3b (๐Ÿ˜๏ธ) + +"#]] + ); + + Ok(()) +} + +#[test] +fn single_stack_ambiguous() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/single-stack-ambiguous")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 20de6ee (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 70e9a36 (B) with-ref +* 320e105 (tag: without-ref) segment-B +* 2a31450 (ambiguous-01, B-empty) segment-B~1 +* 70bde6b (origin/B, A-empty-03, A-empty-02, A-empty-01, A) segment-A +* fafd9d0 (origin/main, new-B, new-A, main) init + +"#]] + ); + + // Just a workspace, no additional ref information. + // As the segments are ambiguous, there are many unnamed segments. + add_workspace(&mut meta); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท20de6ee (โŒ‚|๐Ÿ˜) +* ยท70e9a36 (โŒ‚|๐Ÿ˜) โ–บB <> origin/B +* ยท320e105 (โŒ‚|๐Ÿ˜) โ–บtags/without-ref +* ยท2a31450 (โŒ‚|๐Ÿ˜) โ–บB-empty, โ–บambiguous-01 +* ยท70bde6b (โŒ‚|๐Ÿ˜) โ–บA, โ–บA-empty-01, โ–บA-empty-02, โ–บA-empty-03, โ–บorigin/B +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บnew-A, โ–บnew-B, โ–บorigin/main <> origin/main +layout: + materialized parents: 20de6ee: 70e9a36 +"#]] + ); + + // All non-integrated segments are visible. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:B <> origin/Bโ‡ก3 on fafd9d0 + โ””โ”€โ”€ :B <> origin/Bโ‡ก3 + โ”œโ”€โ”€ ยท70e9a36 (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) โ–บtags/without-ref + โ”œโ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) โ–บB-empty, โ–บambiguous-01 + โ””โ”€โ”€ โ„๏ธ70bde6b (๐Ÿ˜๏ธ) โ–บA, โ–บA-empty-01, โ–บA-empty-02, โ–บA-empty-03 + +"#]] + ); + + // There is always a segment for the entrypoint, and code working with the graph + // deals with that naturally. + let (without_ref_id, ref_name) = id_at(&repo, "without-ref"); + let ws = Workspace::from_tip( + without_ref_id, + ref_name, + &*meta, + project_meta(&*meta), + standard_options(), + )? + .validated()?; + // See how tags ARE allowed to name a segment, at least when used as entrypoint. + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ยท20de6ee (โŒ‚|๐Ÿ˜) +* ยท70e9a36 (โŒ‚|๐Ÿ˜) โ–บB <> origin/B +* ๐Ÿ‘‰ยท320e105 (โŒ‚|๐Ÿ˜) โ–บtags/without-ref +* ยท2a31450 (โŒ‚|๐Ÿ˜) โ–บB-empty, โ–บambiguous-01 +* ยท70bde6b (โŒ‚|๐Ÿ˜) โ–บA, โ–บA-empty-01, โ–บA-empty-02, โ–บA-empty-03, โ–บorigin/B +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บnew-A, โ–บnew-B, โ–บorigin/main <> origin/main +"#]] + ); + // Now `HEAD` is outside a workspace, which goes to single-branch mode. But it knows it's in a workspace + // and shows the surrounding parts, while marking the segment as entrypoint. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:B <> origin/Bโ‡ก1 on fafd9d0 + โ”œโ”€โ”€ :B <> origin/Bโ‡ก1 + โ”‚ โ””โ”€โ”€ ยท70e9a36 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ๐Ÿ‘‰:tags/without-ref + โ”œโ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) โ–บB-empty, โ–บambiguous-01 + โ””โ”€โ”€ โ„70bde6b (๐Ÿ˜๏ธ) โ–บA, โ–บA-empty-01, โ–บA-empty-02, โ–บA-empty-03 + +"#]] + ); + + // We don't have to give it a ref-name + let ws = Workspace::from_tip( + without_ref_id, + None, + &*meta, + project_meta(&*meta), + standard_options(), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ยท20de6ee (โŒ‚|๐Ÿ˜) +* ยท70e9a36 (โŒ‚|๐Ÿ˜) โ–บB <> origin/B +* ๐Ÿ‘‰ยท320e105 (โŒ‚|๐Ÿ˜) โ–บtags/without-ref +* ยท2a31450 (โŒ‚|๐Ÿ˜) โ–บB-empty, โ–บambiguous-01 +* ยท70bde6b (โŒ‚|๐Ÿ˜) โ–บA, โ–บA-empty-01, โ–บA-empty-02, โ–บA-empty-03, โ–บorigin/B +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บnew-A, โ–บnew-B, โ–บorigin/main <> origin/main +"#]] + ); + + // Entrypoint is now unnamed (as no ref-name was provided for traversal) + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:B <> origin/Bโ‡ก1 on fafd9d0 + โ”œโ”€โ”€ :B <> origin/Bโ‡ก1 + โ”‚ โ””โ”€โ”€ ยท70e9a36 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ๐Ÿ‘‰:anon: + โ”œโ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) โ–บtags/without-ref + โ”œโ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) โ–บB-empty, โ–บambiguous-01 + โ””โ”€โ”€ โ„70bde6b (๐Ÿ˜๏ธ) โ–บA, โ–บA-empty-01, โ–บA-empty-02, โ–บA-empty-03 + +"#]] + ); + + // Putting the entrypoint onto a commit in an anonymous segment with ambiguous refs makes no difference. + let (b_id_1, tag_ref_name) = id_at(&repo, "B-empty"); + let ws = Workspace::from_tip( + b_id_1, + None, + &*meta, + project_meta(&*meta), + standard_options(), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ยท20de6ee (โŒ‚|๐Ÿ˜) +* ยท70e9a36 (โŒ‚|๐Ÿ˜) โ–บB <> origin/B +* ยท320e105 (โŒ‚|๐Ÿ˜) โ–บtags/without-ref +* ๐Ÿ‘‰ยท2a31450 (โŒ‚|๐Ÿ˜) โ–บB-empty, โ–บambiguous-01 +* ยท70bde6b (โŒ‚|๐Ÿ˜) โ–บA, โ–บA-empty-01, โ–บA-empty-02, โ–บA-empty-03, โ–บorigin/B +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บnew-A, โ–บnew-B, โ–บorigin/main <> origin/main +"#]] + ); + + // Doing this is very much like edit mode, and there is always a segment starting at the entrypoint. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:B <> origin/Bโ‡ก2 on fafd9d0 + โ”œโ”€โ”€ :B <> origin/Bโ‡ก2 + โ”‚ โ”œโ”€โ”€ ยท70e9a36 (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) โ–บtags/without-ref + โ””โ”€โ”€ ๐Ÿ‘‰:anon: + โ”œโ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) โ–บB-empty, โ–บambiguous-01 + โ””โ”€โ”€ โ„70bde6b (๐Ÿ˜๏ธ) โ–บA, โ–บA-empty-01, โ–บA-empty-02, โ–บA-empty-03 + +"#]] + ); + + // If we pass an entrypoint ref name, it will be used as segment name (despite being ambiguous without it) + let ws = Workspace::from_tip( + b_id_1, + tag_ref_name, + &*meta, + project_meta(&*meta), + standard_options(), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ยท20de6ee (โŒ‚|๐Ÿ˜) +* ยท70e9a36 (โŒ‚|๐Ÿ˜) โ–บB <> origin/B +* ยท320e105 (โŒ‚|๐Ÿ˜) โ–บtags/without-ref +* ๐Ÿ‘‰ยท2a31450 (โŒ‚|๐Ÿ˜) โ–บB-empty, โ–บambiguous-01 +* ยท70bde6b (โŒ‚|๐Ÿ˜) โ–บA, โ–บA-empty-01, โ–บA-empty-02, โ–บA-empty-03, โ–บorigin/B +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บnew-A, โ–บnew-B, โ–บorigin/main <> origin/main +"#]] + ); + + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:B <> origin/Bโ‡ก2 on fafd9d0 + โ”œโ”€โ”€ :B <> origin/Bโ‡ก2 + โ”‚ โ”œโ”€โ”€ ยท70e9a36 (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) โ–บtags/without-ref + โ””โ”€โ”€ ๐Ÿ‘‰:B-empty + โ”œโ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) โ–บambiguous-01 + โ””โ”€โ”€ โ„70bde6b (๐Ÿ˜๏ธ) โ–บA, โ–บA-empty-01, โ–บA-empty-02, โ–บA-empty-03 + +"#]] + ); + Ok(()) +} + +#[test] +fn single_stack_ws_insertions() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/single-stack-ambiguous")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 20de6ee (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 70e9a36 (B) with-ref +* 320e105 (tag: without-ref) segment-B +* 2a31450 (ambiguous-01, B-empty) segment-B~1 +* 70bde6b (origin/B, A-empty-03, A-empty-02, A-empty-01, A) segment-A +* fafd9d0 (origin/main, new-B, new-A, main) init + +"#]] + ); + // Fully defined workspace with multiple empty segments on top of each other. + // Notably the order doesn't match, 'B-empty' is after 'B', but we use it anyway for segment definition. + // On single commits, the desired order fully defines where stacks go. + // Note that this does match the single-stack (one big segment) configuration we actually have. + add_stack_with_segments( + &mut meta, + 0, + "B-empty", + StackState::InWorkspace, + &[ + "B", + "A-empty-03", + /* A-empty-02 purposefully missing */ "not-A-empty-02", + "A-empty-01", + "A", + ], + ); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท20de6ee (โŒ‚|๐Ÿ˜) +* ยท70e9a36 (โŒ‚|๐Ÿ˜) โ–บB <> origin/B +* ยท320e105 (โŒ‚|๐Ÿ˜) โ–บtags/without-ref +* ยท2a31450 (โŒ‚|๐Ÿ˜) โ–บB-empty, โ–บambiguous-01 +* ยท70bde6b (โŒ‚|๐Ÿ˜) โ–บA, โ–บA-empty-01, โ–บA-empty-02, โ–บA-empty-03, โ–บorigin/B +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บnew-A, โ–บnew-B, โ–บorigin/main <> origin/main +layout: + materialized parents: 20de6ee: 70e9a36 + empty chain anchors: 2a31450^ +"#]] + ); + + // We pickup empty segments. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B <> origin/Bโ‡ก2 on fafd9d0 {0} + โ”œโ”€โ”€ ๐Ÿ“™:B <> origin/Bโ‡ก2 + โ”‚ โ”œโ”€โ”€ ยท70e9a36 (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) โ–บtags/without-ref + โ”œโ”€โ”€ ๐Ÿ“™:B-empty + โ”‚ โ””โ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) โ–บambiguous-01 + โ”œโ”€โ”€ ๐Ÿ“™:A-empty-03 + โ”œโ”€โ”€ ๐Ÿ“™:A-empty-01 + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ โ„70bde6b (๐Ÿ˜๏ธ) โ–บA-empty-02 + +"#]] + ); + + // Now something similar but with two stacks. + // As the actual topology is different, we can't really comply with that's desired. + // Instead, we reuse as many of the named segments as possible, even if they are from multiple branches. + meta.data_mut().branches.clear(); + add_stack_with_segments(&mut meta, 0, "B-empty", StackState::InWorkspace, &["B"]); + add_stack_with_segments( + &mut meta, + 1, + "A-empty-03", + StackState::InWorkspace, + &["A-empty-02", "A-empty-01", "A"], + ); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท20de6ee (โŒ‚|๐Ÿ˜) +* ยท70e9a36 (โŒ‚|๐Ÿ˜) โ–บB <> origin/B +* ยท320e105 (โŒ‚|๐Ÿ˜) โ–บtags/without-ref +* ยท2a31450 (โŒ‚|๐Ÿ˜) โ–บB-empty, โ–บambiguous-01 +* ยท70bde6b (โŒ‚|๐Ÿ˜) โ–บA, โ–บA-empty-01, โ–บA-empty-02, โ–บA-empty-03, โ–บorigin/B +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บnew-A, โ–บnew-B, โ–บorigin/main <> origin/main +layout: + materialized parents: 20de6ee: 70e9a36 + empty chain anchors: 2a31450^ 70bde6b^ +"#]] + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B <> origin/Bโ‡ก2 on fafd9d0 {0} + โ”œโ”€โ”€ ๐Ÿ“™:B <> origin/Bโ‡ก2 + โ”‚ โ”œโ”€โ”€ ยท70e9a36 (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) โ–บtags/without-ref + โ”œโ”€โ”€ ๐Ÿ“™:B-empty + โ”‚ โ””โ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) โ–บambiguous-01 + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ โ„70bde6b (๐Ÿ˜๏ธ) + +"#]] + ); + + // Define only some of the branches, it should figure that out. + // It respects the order of the mention in the stack, `A` before `A-empty-01`. + meta.data_mut().branches.clear(); + add_stack_with_segments(&mut meta, 0, "A", StackState::InWorkspace, &["A-empty-01"]); + add_stack_with_segments(&mut meta, 1, "B-empty", StackState::InWorkspace, &["B"]); + + let (id, ref_name) = id_at(&repo, "A-empty-01"); + let ws = Workspace::from_tip( + id, + ref_name, + &*meta, + project_meta(&*meta), + standard_options(), + )?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ยท20de6ee (โŒ‚|๐Ÿ˜) +* ยท70e9a36 (โŒ‚|๐Ÿ˜) โ–บB <> origin/B +* ยท320e105 (โŒ‚|๐Ÿ˜) โ–บtags/without-ref +* ยท2a31450 (โŒ‚|๐Ÿ˜) โ–บB-empty, โ–บambiguous-01 +* ๐Ÿ‘‰ยท70bde6b (โŒ‚|๐Ÿ˜) โ–บA, โ–บA-empty-01, โ–บA-empty-02, โ–บA-empty-03, โ–บorigin/B +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บnew-A, โ–บnew-B, โ–บorigin/main <> origin/main +layout: + empty chain anchors: 70bde6b^ 2a31450^ +"#]] + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B <> origin/Bโ‡ก2 on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:B <> origin/Bโ‡ก2 + โ”‚ โ”œโ”€โ”€ ยท70e9a36 (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) โ–บtags/without-ref + โ”œโ”€โ”€ ๐Ÿ“™:B-empty + โ”‚ โ””โ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) โ–บambiguous-01 + โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:A-empty-01 + โ””โ”€โ”€ โ„70bde6b (๐Ÿ˜๏ธ) โ–บA-empty-02, โ–บA-empty-03 + +"#]] + ); + + add_stack_with_segments(&mut meta, 2, "new-A", StackState::InWorkspace, &[]); + add_stack_with_segments(&mut meta, 3, "new-B", StackState::InWorkspace, &[]); + + let (id, ref_name) = id_at(&repo, "new-A"); + let ws = Workspace::from_tip( + id, + ref_name, + &*meta, + project_meta(&*meta), + standard_options(), + )?; + + // We can also summon new empty stacks from branches resting on the base, and set them + // as entrypoint, to have two more stacks. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B <> origin/Bโ‡ก2 on fafd9d0 {1} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B <> origin/Bโ‡ก2 +โ”‚ โ”‚ โ”œโ”€โ”€ ยท70e9a36 (๐Ÿ˜๏ธ) +โ”‚ โ”‚ โ””โ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) โ–บtags/without-ref +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B-empty +โ”‚ โ”‚ โ””โ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) โ–บambiguous-01 +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A-empty-01 +โ”‚ โ””โ”€โ”€ โ„70bde6b (๐Ÿ˜๏ธ) โ–บA-empty-02, โ–บA-empty-03 +โ”œโ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:new-A on fafd9d0 {2} +โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:new-A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:new-B on fafd9d0 {3} + โ””โ”€โ”€ ๐Ÿ“™:new-B + +"#]] + ); + Ok(()) +} + +#[test] +fn first_parent_reachability_traverses_empty_segments() -> anyhow::Result<()> { + // Regression guard: deriving the first-parent edge from the source commit's `parent_ids` must + // not dead-end when a commit-less branch segment sits on the first-parent path. The same + // `ws/single-stack-ambiguous` setup as `single_stack_ws_insertions` puts the empty segments + // `A-empty-03`/`A-empty-01` between commit `2a31450` (B's side) and `70bde6b` (origin/B / A). + // A first-parent excluded walk from `B` must descend through those empties to reach `70bde6b` + // and `fafd9d0`; otherwise they leak into `origin/B..B` even though both are first-parent + // ancestors of `B`. + let (repo, mut meta) = read_only_in_memory_scenario("ws/single-stack-ambiguous")?; + add_stack_with_segments( + &mut meta, + 0, + "B-empty", + StackState::InWorkspace, + &["B", "A-empty-03", "not-A-empty-02", "A-empty-01", "A"], + ); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + + let b = repo.rev_parse_single("B")?.detach(); // 70e9a36, above the empty chain + let origin_b = repo.rev_parse_single("origin/B")?.detach(); // 70bde6b, below it + + // `origin/B` (70bde6b) is a first-parent ancestor of `B`, so nothing is reachable from it but + // not from `B`. + let leaked = ws.commit_ids_in_a_not_b(origin_b, b, but_graph::FirstParent::Yes)?; + assert!( + leaked.is_empty(), + "empty segments on the first-parent path dead-ended the excluded walk, leaking \ + first-parent ancestors of B into origin/B..B: {leaked:?}" + ); + Ok(()) +} + +#[test] +fn single_stack() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/single-stack")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 2c12d75 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 320e105 (B) segment-B +* 2a31450 (B-sub) segment-B~1 +* 70bde6b (A) segment-A +* fafd9d0 (origin/main, new-A, main) init + +"#]] + ); + + // Just a workspace, no additional ref information. + // It segments across the unambiguous ref names. + add_workspace(&mut meta); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท2c12d75 (โŒ‚|๐Ÿ˜) +* ยท320e105 (โŒ‚|๐Ÿ˜) โ–บB +* ยท2a31450 (โŒ‚|๐Ÿ˜) โ–บB-sub +* ยท70bde6b (โŒ‚|๐Ÿ˜) โ–บA +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บnew-A, โ–บorigin/main <> origin/main +layout: + materialized parents: 2c12d75: 320e105 +"#]] + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:B on fafd9d0 + โ”œโ”€โ”€ :B + โ”‚ โ””โ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ :B-sub + โ”‚ โ””โ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :A + โ””โ”€โ”€ ยท70bde6b (๐Ÿ˜๏ธ) + +"#]] + ); + + meta.data_mut().branches.clear(); + // Just repeat the existing segment verbatim, but also add a new unborn stack + add_stack_with_segments(&mut meta, 0, "B", StackState::InWorkspace, &["B-sub", "A"]); + add_stack_with_segments( + &mut meta, + 1, + "new-A", + StackState::InWorkspace, + &["below-new-A"], + ); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท2c12d75 (โŒ‚|๐Ÿ˜) +* ยท320e105 (โŒ‚|๐Ÿ˜) โ–บB +* ยท2a31450 (โŒ‚|๐Ÿ˜) โ–บB-sub +* ยท70bde6b (โŒ‚|๐Ÿ˜) โ–บA +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บnew-A, โ–บorigin/main <> origin/main +layout: + materialized parents: 2c12d75: 320e105 fafd9d0 + empty chain anchors: 320e105^ fafd9d0 +"#]] + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on fafd9d0 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ”‚ โ””โ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B-sub +โ”‚ โ”‚ โ””โ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ”‚ โ””โ”€โ”€ ยท70bde6b (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:new-A on fafd9d0 {1} + โ””โ”€โ”€ ๐Ÿ“™:new-A + +"#]] + ); + + Ok(()) +} + +#[test] +fn single_merge_into_main_base_archived() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/single-merge-into-main")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 866c905 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* c6d714c (C) C +* 0cc5a6f (origin/main, merge, main) Merge branch 'A' into merge +|\ +| * e255adc (A) A +* | 7fdb58d (B) B +|/ +* fafd9d0 init + +"#]] + .raw() + ); + + let stack_id = add_stack_with_segments(&mut meta, 0, "C", StackState::InWorkspace, &["merge"]); + + // By default, everything with metadata on the branch will show up, even if on the base. + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 0cc5a6f +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 0cc5a6f {0} + โ”œโ”€โ”€ ๐Ÿ“™:C + โ”‚ โ””โ”€โ”€ ยทc6d714c (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ๐Ÿ“™:merge + +"#]] + ); + + // But even if everything is marked as archived, only the ones that matter are hidden. + for head in &mut meta + .data_mut() + .branches + .get_mut(&stack_id) + .expect("just added") + .heads + { + head.archived = true; + } + + let ws = ws.redo(&repo, &*meta, Default::default())?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 0cc5a6f +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 0cc5a6f {0} + โ””โ”€โ”€ ๐Ÿ“™:C + โ””โ”€โ”€ ยทc6d714c (๐Ÿ˜๏ธ) + +"#]] + ); + + // Finally, when the 'merge' branch is independent, it still works as it should. + add_stack_with_segments(&mut meta, 0, "C", StackState::InWorkspace, &[]); + add_stack_with_segments(&mut meta, 1, "merge", StackState::InWorkspace, &[]); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 0cc5a6f +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 0cc5a6f {0} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:C +โ”‚ โ””โ”€โ”€ ยทc6d714c (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:merge on 0cc5a6f {1} + โ””โ”€โ”€ ๐Ÿ“™:merge + +"#]] + ); + + // The order is respected. + add_stack_with_segments(&mut meta, 1, "C", StackState::InWorkspace, &[]); + add_stack_with_segments(&mut meta, 0, "merge", StackState::InWorkspace, &[]); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 0cc5a6f +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:merge on 0cc5a6f {0} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:merge +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 0cc5a6f {1} + โ””โ”€โ”€ ๐Ÿ“™:C + โ””โ”€โ”€ ยทc6d714c (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +#[test] +fn minimal_merge_no_refs() -> anyhow::Result<()> { + let (repo, meta) = read_only_in_memory_scenario("ws/dual-merge-no-refs")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 47e1cf1 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* f40fb16 Merge branch 'C' into merge-2 +|\ +| * c6d714c C +* | 450c58a D +|/ +* 0cc5a6f Merge branch 'A' into merge +|\ +| * e255adc A +* | 7fdb58d B +|/ +* fafd9d0 init + +"#]] + .raw() + ); + + // Without hints. + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท47e1cf1 (โŒ‚) +* ยทf40fb16 (โŒ‚) +โ”œโ”€โ•ฎ +* โ”‚ ยท450c58a (โŒ‚) +โ”‚ * ยทc6d714c (โŒ‚) +โ”œโ”€โ•ฏ +* ยท0cc5a6f (โŒ‚) +โ”œโ”€โ•ฎ +* โ”‚ ยท7fdb58d (โŒ‚) +โ”‚ * ยทe255adc (โŒ‚) +โ”œโ”€โ•ฏ +* ๐Ÿยทfafd9d0 (โŒ‚) +layout: + materialized parents: 47e1cf1: f40fb16 +"#]] + ); + + // This a very untypical setup, but it's not forbidden. Code might want to check + // if the workspace commit is actually managed before proceeding. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +โŒ‚:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:gitbutler/workspace[๐ŸŒณ] {1} + โ””โ”€โ”€ :gitbutler/workspace[๐ŸŒณ] + โ”œโ”€โ”€ ยท47e1cf1 + โ”œโ”€โ”€ ยทf40fb16 + โ”œโ”€โ”€ ยท450c58a + โ”œโ”€โ”€ ยท0cc5a6f + โ”œโ”€โ”€ ยท7fdb58d + โ””โ”€โ”€ ยทfafd9d0 + +"#]] + ); + Ok(()) +} + +#[test] +fn segment_on_each_incoming_connection() -> anyhow::Result<()> { + // Validate that the graph is truly having segments whenever there is an incoming connection. + // This is required to not need special edge-weights. + let (repo, mut meta) = read_only_in_memory_scenario("ws/graph-splitting")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 98c5aba (entrypoint) C +* 807b6ce B +* 6d05486 A +| * b6917c7 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +| * f7fe830 (main) other-2 +|/ +* b688f2d other-1 +* fafd9d0 init + +"#]] + ); + + // Without hints - needs to split `refs/heads/main` at `b688f2d` + let (id, name) = id_at(&repo, "entrypoint"); + add_workspace(&mut meta); + let ws = Workspace::from_tip(id, name, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท98c5aba (โŒ‚) โ–บentrypoint +* ยท807b6ce (โŒ‚) +* ยท6d05486 (โŒ‚) +โ”‚ * ยทb6917c7 (โŒ‚|๐Ÿ˜) +โ”‚ * ยทf7fe830 (โŒ‚|๐Ÿ˜) โ–บmain +โ”œโ”€โ•ฏ +* ยทb688f2d (โŒ‚|๐Ÿ˜) +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) +"#]] + ); + // This is an unmanaged workspace, even though commits from a workspace flow into it. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +โŒ‚:entrypoint <> โœ“! +โ””โ”€โ”€ โ‰ก:entrypoint {1} + โ””โ”€โ”€ :entrypoint + โ”œโ”€โ”€ ยท98c5aba + โ”œโ”€โ”€ ยท807b6ce + โ”œโ”€โ”€ ยท6d05486 + โ”œโ”€โ”€ ยทb688f2d (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ยทfafd9d0 (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +#[test] +fn minimal_merge() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/dual-merge")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 47e1cf1 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* f40fb16 (merge-2) Merge branch 'C' into merge-2 +|\ +| * c6d714c (C) C +* | 450c58a (D) D +|/ +* 0cc5a6f (merge, empty-2-on-merge, empty-1-on-merge) Merge branch 'A' into merge +|\ +| * e255adc (A) A +* | 7fdb58d (B) B +|/ +* fafd9d0 (origin/main, main) init + +"#]] + .raw() + ); + + // Without hints, and no workspace data, the branch is normal! + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท47e1cf1 (โŒ‚) +* ยทf40fb16 (โŒ‚) โ–บmerge-2 +โ”œโ”€โ•ฎ +* โ”‚ ยท450c58a (โŒ‚) โ–บD +โ”‚ * ยทc6d714c (โŒ‚) โ–บC +โ”œโ”€โ•ฏ +* ยท0cc5a6f (โŒ‚) โ–บempty-1-on-merge, โ–บempty-2-on-merge, โ–บmerge +โ”œโ”€โ•ฎ +* โ”‚ ยท7fdb58d (โŒ‚) โ–บB +โ”‚ * ยทe255adc (โŒ‚) โ–บA +โ”œโ”€โ•ฏ +* ๐Ÿยทfafd9d0 (โŒ‚) โ–บmain, โ–บorigin/main <> origin/main +layout: + materialized parents: 47e1cf1: f40fb16 +"#]] + ); + + // Without workspace data this becomes a single-branch workspace, with `main` as normal segment. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +โŒ‚:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:gitbutler/workspace[๐ŸŒณ] {1} + โ”œโ”€โ”€ :gitbutler/workspace[๐ŸŒณ] + โ”‚ โ””โ”€โ”€ ยท47e1cf1 + โ”œโ”€โ”€ :merge-2 + โ”‚ โ””โ”€โ”€ ยทf40fb16 + โ”œโ”€โ”€ :D + โ”‚ โ”œโ”€โ”€ ยท450c58a + โ”‚ โ””โ”€โ”€ ยท0cc5a6f โ–บempty-1-on-merge, โ–บempty-2-on-merge, โ–บmerge + โ”œโ”€โ”€ :B + โ”‚ โ””โ”€โ”€ ยท7fdb58d + โ””โ”€โ”€ :main <> origin/main + โ””โ”€โ”€ โ„๏ธfafd9d0 + +"#]] + ); + + // There is empty stacks on top of `merge`, and they need to be connected to the incoming segments and the outgoing ones. + // This also would leave the original segment empty unless we managed to just put empty stacks on top. + add_stack_with_segments( + &mut meta, + 0, + "empty-2-on-merge", + StackState::InWorkspace, + &["empty-1-on-merge", "merge"], + ); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท47e1cf1 (โŒ‚|๐Ÿ˜) +* ยทf40fb16 (โŒ‚|๐Ÿ˜) โ–บmerge-2 +โ”œโ”€โ•ฎ +* โ”‚ ยท450c58a (โŒ‚|๐Ÿ˜) โ–บD +โ”‚ * ยทc6d714c (โŒ‚|๐Ÿ˜) โ–บC +โ”œโ”€โ•ฏ +* ยท0cc5a6f (โŒ‚|๐Ÿ˜) โ–บempty-1-on-merge, โ–บempty-2-on-merge, โ–บmerge +โ”œโ”€โ•ฎ +* โ”‚ ยท7fdb58d (โŒ‚|๐Ÿ˜) โ–บB +โ”‚ * ยทe255adc (โŒ‚|๐Ÿ˜) โ–บA +โ”œโ”€โ•ฏ +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บorigin/main <> origin/main +layout: + materialized parents: 47e1cf1: f40fb16 + empty chain anchors: 0cc5a6f^ +"#]] + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:merge-2 on fafd9d0 {0} + โ”œโ”€โ”€ :merge-2 + โ”‚ โ””โ”€โ”€ ยทf40fb16 (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ :D + โ”‚ โ””โ”€โ”€ ยท450c58a (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ ๐Ÿ“™:empty-2-on-merge + โ”œโ”€โ”€ ๐Ÿ“™:empty-1-on-merge + โ”œโ”€โ”€ ๐Ÿ“™:merge + โ”‚ โ””โ”€โ”€ ยท0cc5a6f (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :B + โ””โ”€โ”€ ยท7fdb58d (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +#[test] +fn entrypoint_inside_second_parent_of_workspace_diamond_is_included() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/dual-merge")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 47e1cf1 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* f40fb16 (merge-2) Merge branch 'C' into merge-2 +|\ +| * c6d714c (C) C +* | 450c58a (D) D +|/ +* 0cc5a6f (merge, empty-2-on-merge, empty-1-on-merge) Merge branch 'A' into merge +|\ +| * e255adc (A) A +* | 7fdb58d (B) B +|/ +* fafd9d0 (origin/main, main) init + +"#]] + .raw() + ); + add_workspace(&mut meta); + let (id, name) = id_at(&repo, "C"); + let ws = Workspace::from_tip(id, name, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ยท47e1cf1 (โŒ‚|๐Ÿ˜) +* ยทf40fb16 (โŒ‚|๐Ÿ˜) โ–บmerge-2 +โ”œโ”€โ•ฎ +* โ”‚ ยท450c58a (โŒ‚|๐Ÿ˜) โ–บD +โ”‚ * ๐Ÿ‘‰ยทc6d714c (โŒ‚|๐Ÿ˜) โ–บC +โ”œโ”€โ•ฏ +* ยท0cc5a6f (โŒ‚|๐Ÿ˜) โ–บempty-1-on-merge, โ–บempty-2-on-merge, โ–บmerge +โ”œโ”€โ•ฎ +* โ”‚ ยท7fdb58d (โŒ‚|๐Ÿ˜) โ–บB +โ”‚ * ยทe255adc (โŒ‚|๐Ÿ˜) โ–บA +โ”œโ”€โ•ฏ +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บorigin/main <> origin/main +"#]] + ); + + let entrypoint_stack_segment = ws + .stacks + .iter() + .flat_map(|stack| stack.segments.iter()) + .find(|segment| segment.is_entrypoint) + .expect("entrypoint segment must stay in a workspace stack"); + assert!( + entrypoint_stack_segment + .commits + .iter() + .any(|commit| commit.id == id.detach()), + "the entrypoint stack segment must contain the custom traversal commit" + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:merge-2 on fafd9d0 + โ”œโ”€โ”€ :merge-2 + โ”‚ โ””โ”€โ”€ ยทf40fb16 (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ ๐Ÿ‘‰:C + โ”‚ โ”œโ”€โ”€ ยทc6d714c (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ ยท0cc5a6f (๐Ÿ˜๏ธ) โ–บempty-1-on-merge, โ–บempty-2-on-merge, โ–บmerge + โ””โ”€โ”€ :B + โ””โ”€โ”€ ยท7fdb58d (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +#[test] +fn stack_configuration_is_respected_if_one_of_them_is_an_entrypoint() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/just-init-with-two-branches")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* fafd9d0 (HEAD -> gitbutler/workspace, main, B, A) init + +"#]] + ); + + add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &[]); + add_stack_with_segments(&mut meta, 2, "B", StackState::InWorkspace, &[]); + + let extra_target_options = standard_options_with_extra_target(&repo, "main"); + let ws = Workspace::from_head( + &repo, + &*meta, + project_meta(&*meta), + extra_target_options.clone(), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) โ–บA, โ–บB, โ–บmain +layout: + empty chain anchors: fafd9d0 fafd9d0 +"#]] + ); + assert_eq!( + ws.entrypoint_commit_id()?, + extra_target_options.extra_target_commit_id, + "entrypoint points to a virtual workspace tip segment \ + which can't unambiguously find the commit" + ); + assert_eq!( + ws.tip_commit_id(), + extra_target_options.extra_target_commit_id, + "workspace query falls back to the ref-info commit for ambiguous empty segments" + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on fafd9d0 {2} + โ””โ”€โ”€ ๐Ÿ“™:B + +"#]] + ); + + let (id, ref_name) = id_at(&repo, "B"); + let ws = Workspace::from_tip( + id, + ref_name.clone(), + &*meta, + project_meta(&*meta), + extra_target_options.clone(), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) โ–บA, โ–บB, โ–บmain +layout: + empty chain anchors: fafd9d0 fafd9d0 +"#]] + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:B on fafd9d0 {2} + โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:B + +"#]] + ); + + let (id, ref_name) = id_at(&repo, "A"); + let ws = Workspace::from_tip( + id, + ref_name.clone(), + &*meta, + project_meta(&*meta), + extra_target_options, + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) โ–บA, โ–บB, โ–บmain +layout: + empty chain anchors: fafd9d0 fafd9d0 +"#]] + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:A on fafd9d0 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on fafd9d0 {2} + โ””โ”€โ”€ ๐Ÿ“™:B + +"#]] + ); + + Ok(()) +} + +#[test] +fn just_init_with_branches() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/just-init-with-branches")?; + // Note the dedicated workspace branch without a workspace commit. + // All is fair game, and we use it to validate 'empty parent branch handling after new children took the commit'. + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* fafd9d0 (HEAD -> main, origin/main, gitbutler/workspace, F, E, D, C, B, A) init + +"#]] + ); + + // Without hints - `main` is picked up as it's the entrypoint. + add_workspace(&mut meta); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![ + "* ๐Ÿ‘‰๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บA, โ–บB, โ–บC, โ–บD, โ–บE, โ–บF, โ–บmain[๐ŸŒณ], โ–บorigin/main <> origin/main" + ] + ); + + // There is no workspace as `main` is the base of the workspace, so it's shown directly + // as a downgraded single-branch view. The target context is preserved, and the fully + // integrated base commit is pruned while keeping the branch container. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +โŒ‚:main[๐ŸŒณ] <> โœ“refs/remotes/origin/main +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] <> origin/main {1} + โ””โ”€โ”€ :main[๐ŸŒณ] <> origin/main + +"#]] + ); + + let (id, ws_ref_name) = id_at(&repo, "gitbutler/workspace"); + let ws = Workspace::from_tip( + id, + ws_ref_name.clone(), + &*meta, + but_core::ref_metadata::ProjectMeta::default(), + standard_options(), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![ + "* ๐Ÿ‘‰๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) โ–บA, โ–บB, โ–บC, โ–บD, โ–บE, โ–บF, โ–บmain[๐ŸŒณ], โ–บorigin/main <> origin/main" + ] + ); + + // However, when the workspace is checked out, it's at least empty. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“! +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] <> origin/main + โ””โ”€โ”€ :main[๐ŸŒณ] <> origin/main + โ””โ”€โ”€ โ„๏ธfafd9d0 (๐Ÿ˜๏ธ) โ–บA, โ–บB, โ–บC, โ–บD, โ–บE, โ–บF + +"#]] + ); + + // The simplest possible setup where we can define how the workspace should look like, + // in terms of dependent and independent virtual segments. + add_stack_with_segments(&mut meta, 0, "C", StackState::InWorkspace, &["B", "A"]); + add_stack_with_segments(&mut meta, 1, "D", StackState::InWorkspace, &["E", "F"]); + + let ws = Workspace::from_head( + &repo, + &*meta, + but_core::ref_metadata::ProjectMeta::default(), + standard_options(), + )?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) โ–บA, โ–บB, โ–บC, โ–บD, โ–บE, โ–บF, โ–บmain[๐ŸŒณ], โ–บorigin/main <> origin/main +layout: + empty chain anchors: fafd9d0 fafd9d0 +"#]] + ); + + // With empty project metadata, workspace segmentation is retained around the workspace ref. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“! on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on fafd9d0 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:D on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:D + โ”œโ”€โ”€ ๐Ÿ“™:E + โ””โ”€โ”€ ๐Ÿ“™:F + +"#]] + ); + + let ws = Workspace::from_tip( + id, + ws_ref_name.clone(), + &*meta, + project_meta(&*meta), + standard_options(), + )? + .validated()?; + // Now the dependent segments are applied, and so is the separate stack. + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บA, โ–บB, โ–บC, โ–บD, โ–บE, โ–บF, โ–บmain[๐ŸŒณ], โ–บorigin/main <> origin/main +layout: + empty chain anchors: fafd9d0 fafd9d0 +"#]] + ); + + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on fafd9d0 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:D on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:D + โ”œโ”€โ”€ ๐Ÿ“™:E + โ””โ”€โ”€ ๐Ÿ“™:F + +"#]] + ); + + let ws = ws.anonymized(&repo.remote_names())?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:G <> โœ“refs/remotes/remote-0/H on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:D on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:D + โ”œโ”€โ”€ ๐Ÿ“™:E + โ””โ”€โ”€ ๐Ÿ“™:F + +"#]] + ); + + Ok(()) +} + +#[test] +fn tips_equivalent_to_workspace_metadata_are_order_independent() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/just-init-with-branches")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* fafd9d0 (HEAD -> main, origin/main, gitbutler/workspace, F, E, D, C, B, A) init + +"#]] + ); + + add_workspace(&mut meta); + add_stack_with_segments(&mut meta, 0, "C", StackState::InWorkspace, &["B", "A"]); + add_stack_with_segments(&mut meta, 1, "D", StackState::InWorkspace, &["E", "F"]); + + let (id, ws_ref_name) = id_at(&repo, "gitbutler/workspace"); + let commit_id = id.detach(); + let workspace_metadata = (*meta.workspace(ws_ref_name.as_ref())?).clone(); + let main_ref = super::ref_name("refs/heads/main"); + let origin_main_ref = super::ref_name("refs/remotes/origin/main"); + let stack_ref = |name: &str| super::ref_name(&format!("refs/heads/{name}")); + + let head_baseline = + Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + let head_baseline_tree = graph_dag(&head_baseline); + let head_baseline_workspace = graph_workspace(&head_baseline).to_string(); + + let head_tips = vec![ + Seed::new(commit_id).with_role(SeedRole::WorkspaceStackBranch { + desired_ref_name: stack_ref("F"), + }), + Seed::new(commit_id) + .with_ref_name(Some(ws_ref_name.clone())) + .with_role(SeedRole::Workspace) + .with_metadata(SegmentMetadata::Workspace(workspace_metadata.clone())), + Seed::new(commit_id).with_role(SeedRole::WorkspaceStackBranch { + desired_ref_name: stack_ref("B"), + }), + Seed::new(commit_id) + .with_ref_name(Some(origin_main_ref.clone())) + .with_role(SeedRole::TargetRemote), + Seed::new(commit_id).with_role(SeedRole::WorkspaceStackBranch { + desired_ref_name: stack_ref("A"), + }), + Seed::new(commit_id) + .with_ref_name(Some(main_ref.clone())) + .with_entrypoint(), + Seed::new(commit_id).with_role(SeedRole::WorkspaceStackBranch { + desired_ref_name: stack_ref("E"), + }), + Seed::new(commit_id).with_role(SeedRole::WorkspaceStackBranch { + desired_ref_name: stack_ref("C"), + }), + Seed::new(commit_id).with_role(SeedRole::WorkspaceStackBranch { + desired_ref_name: stack_ref("D"), + }), + ]; + + let workspace_baseline = Workspace::from_tip( + id, + ws_ref_name.clone(), + &*meta, + project_meta(&*meta), + standard_options(), + )? + .validated()?; + let workspace_baseline_tree = graph_dag(&workspace_baseline); + let workspace_baseline_workspace = graph_workspace(&workspace_baseline); + snapbox::assert_data_eq!( + workspace_baseline_workspace.to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on fafd9d0 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:D on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:D + โ”œโ”€โ”€ ๐Ÿ“™:E + โ””โ”€โ”€ ๐Ÿ“™:F + +"#]] + ); + let workspace_baseline_workspace = workspace_baseline_workspace.to_string(); + + let explicit_seeds = vec![ + Seed::new(commit_id).with_role(SeedRole::WorkspaceStackBranch { + desired_ref_name: stack_ref("E"), + }), + Seed::new(commit_id).with_role(SeedRole::TargetLocal { + local_ref_name: main_ref.clone(), + }), + Seed::new(commit_id) + .with_ref_name(Some(ws_ref_name.clone())) + .with_role(SeedRole::Workspace) + .with_metadata(SegmentMetadata::Workspace(workspace_metadata)) + .with_entrypoint(), + Seed::new(commit_id).with_role(SeedRole::WorkspaceStackBranch { + desired_ref_name: stack_ref("C"), + }), + Seed::new(commit_id) + .with_ref_name(Some(origin_main_ref)) + .with_role(SeedRole::TargetRemote), + Seed::new(commit_id).with_role(SeedRole::WorkspaceStackBranch { + desired_ref_name: stack_ref("F"), + }), + Seed::new(commit_id).with_role(SeedRole::WorkspaceStackBranch { + desired_ref_name: stack_ref("A"), + }), + Seed::new(commit_id).with_role(SeedRole::WorkspaceStackBranch { + desired_ref_name: stack_ref("D"), + }), + Seed::new(commit_id).with_role(SeedRole::WorkspaceStackBranch { + desired_ref_name: stack_ref("B"), + }), + ]; + // NOTE: `from_seeds` remains WALK-backed (explicit seeds have no flip + // counterpart yet), while `from_head`/`from_tip` build via the flip โ€” the + // cross-API tree equality of the walk era no longer applies. Order-independence is asserted + // by snapshotting both orderings directly. + let ws = Workspace::from_seeds( + &repo, + head_tips, + &*meta, + project_meta(&*meta), + standard_options(), + )? + .validated()?; + let _ = (head_baseline_tree, head_baseline_workspace); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +โŒ‚:main[๐ŸŒณ] <> โœ“refs/remotes/origin/main +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] <> origin/main {1} + โ””โ”€โ”€ :main[๐ŸŒณ] <> origin/main + +"#]] + ); + + let ws = Workspace::from_seeds( + &repo, + explicit_seeds.clone(), + &*meta, + project_meta(&*meta), + standard_options(), + )? + .validated()?; + let _ = workspace_baseline_tree; + let explicit_workspace = graph_workspace(&ws); + snapbox::assert_data_eq!( + explicit_workspace.to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on fafd9d0 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:D on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:D + โ”œโ”€โ”€ ๐Ÿ“™:E + โ””โ”€โ”€ ๐Ÿ“™:F + +"#]] + ); + let _ = (explicit_workspace, workspace_baseline_workspace); + + Ok(()) +} + +#[test] +fn workspace_target_commit_and_extra_target_commit_can_overlap() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/just-init-with-two-branches")?; + let target_id = id_by_rev(&repo, "main").detach(); + add_workspace_with_target(&mut meta, target_id); + add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &[]); + add_stack_with_segments(&mut meta, 2, "B", StackState::InWorkspace, &[]); + + let baseline = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + let baseline_tree = graph_dag(&baseline); + let baseline_workspace = graph_workspace(&baseline).to_string(); + + let ws = Workspace::from_head( + &repo, + &*meta, + project_meta(&*meta), + standard_options().with_extra_target_commit_id(target_id), + )? + .validated()?; + + assert_eq!( + graph_dag(&ws), + baseline_tree, + "duplicated synthetic integrated tips should not change graph traversal" + ); + assert_eq!( + graph_workspace(&ws).to_string(), + baseline_workspace, + "duplicated synthetic integrated tips should not change workspace projection" + ); + + Ok(()) +} + +#[test] +fn duplicate_workspace_stack_branch_tips_from_metadata_are_ignored() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/just-init-with-two-branches")?; + add_workspace(&mut meta); + add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &[]); + add_stack_with_segments(&mut meta, 2, "B", StackState::InWorkspace, &[]); + + let baseline = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + let baseline_tree = graph_dag(&baseline); + let baseline_workspace = graph_workspace(&baseline).to_string(); + + add_stack_with_segments(&mut meta, 3, "B", StackState::InWorkspace, &[]); + let ws = Workspace::from_head( + &repo, + &*meta, + but_core::ref_metadata::ProjectMeta::default(), + standard_options(), + )? + .validated()?; + + assert_eq!( + graph_dag(&ws), + baseline_tree, + "duplicate stack branch metadata (B) should not enqueue the same stack branch traversal twice" + ); + assert_eq!( + graph_workspace(&ws).to_string(), + baseline_workspace, + "duplicate stack branch metadata should not change workspace projection" + ); + + Ok(()) +} + +#[test] +fn just_init_with_archived_branches() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/just-init-with-branches")?; + // Note the dedicated workspace branch without a workspace commit. + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* fafd9d0 (HEAD -> main, origin/main, gitbutler/workspace, F, E, D, C, B, A) init + +"#]] + ); + + let stack_id = add_stack_with_segments(&mut meta, 0, "C", StackState::InWorkspace, &["B", "A"]); + + let (id, ws_ref_name) = id_at(&repo, "gitbutler/workspace"); + let ws = Workspace::from_tip( + id, + ws_ref_name.clone(), + &*meta, + project_meta(&*meta), + standard_options(), + )? + .validated()?; + + // By default, we see both stacks as they are configured, which disambiguates them. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on fafd9d0 {0} + โ”œโ”€โ”€ ๐Ÿ“™:C + โ”œโ”€โ”€ ๐Ÿ“™:B + โ””โ”€โ”€ ๐Ÿ“™:A + +"#]] + ); + + meta.data_mut() + .branches + .get_mut(&stack_id) + .expect("just added") + .heads[1] + .archived = true; + + // The first archived segment causes everything else to be hidden. + let ws = ws.redo(&repo, &*meta, Default::default())?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C {0} + โ””โ”€โ”€ ๐Ÿ“™:C + +"#]] + ); + + let heads = &mut meta.data_mut().branches.get_mut(&stack_id).unwrap().heads; + heads[0].archived = true; + heads[1].archived = false; + + // Now only the first one is archived. + let ws = ws.redo(&repo, &*meta, Default::default())?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C {0} + โ”œโ”€โ”€ ๐Ÿ“™:C + โ””โ”€โ”€ ๐Ÿ“™:B + +"#]] + ); + + let heads = &mut meta.data_mut().branches.get_mut(&stack_id).unwrap().heads; + heads[0].archived = true; + heads[1].archived = true; + heads[2].archived = true; + + // Archiving everything removes the stack entirely. + let ws = ws.redo(&repo, &*meta, Default::default())?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 + +"#]] + ); + Ok(()) +} + +#[test] +fn two_stacks_many_refs() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/one-stacks-many-refs")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 298d938 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 16f132b (S1, G, F) 2 +* 917b9da (E, D) 1 +* fafd9d0 (origin/main, main, C, B, A) init + +"#]] + ); + + add_workspace(&mut meta); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + // Without any information it looks quite barren. + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท298d938 (โŒ‚|๐Ÿ˜) +* ยท16f132b (โŒ‚|๐Ÿ˜) โ–บF, โ–บG, โ–บS1 +* ยท917b9da (โŒ‚|๐Ÿ˜) โ–บD, โ–บE +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บA, โ–บB, โ–บC, โ–บmain, โ–บorigin/main <> origin/main +layout: + materialized parents: 298d938: 16f132b +"#]] + ); + + // With no workspace at all as the workspace segment isn't split. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:anon: on fafd9d0 + โ””โ”€โ”€ :anon: + โ”œโ”€โ”€ ยท16f132b (๐Ÿ˜๏ธ) โ–บF, โ–บG, โ–บS1 + โ””โ”€โ”€ ยท917b9da (๐Ÿ˜๏ธ) โ–บD, โ–บE + +"#]] + ); + + let (id, ref_name) = id_at(&repo, "S1"); + let ws = Workspace::from_tip( + id, + ref_name.clone(), + &*meta, + project_meta(&*meta), + standard_options(), + )? + .validated()?; + // The S1 starting position is a split, so there is more. + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ยท298d938 (โŒ‚|๐Ÿ˜) +* ๐Ÿ‘‰ยท16f132b (โŒ‚|๐Ÿ˜) โ–บF, โ–บG, โ–บS1 +* ยท917b9da (โŒ‚|๐Ÿ˜) โ–บD, โ–บE +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บA, โ–บB, โ–บC, โ–บmain, โ–บorigin/main <> origin/main +"#]] + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:S1 on fafd9d0 + โ””โ”€โ”€ ๐Ÿ‘‰:S1 + โ”œโ”€โ”€ ยท16f132b (๐Ÿ˜๏ธ) โ–บF, โ–บG + โ””โ”€โ”€ ยท917b9da (๐Ÿ˜๏ธ) โ–บD, โ–บE + +"#]] + ); + + // Define the workspace. + add_stack_with_segments(&mut meta, 1, "C", StackState::InWorkspace, &["B"]); + add_stack_with_segments(&mut meta, 2, "A", StackState::InWorkspace, &[]); + add_stack_with_segments(&mut meta, 3, "S1", StackState::InWorkspace, &["G", "F"]); + add_stack_with_segments(&mut meta, 4, "D", StackState::InWorkspace, &["E"]); + + // We see that all segments are used, stacks in metadata order: C B A S1 G F D E + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท298d938 (โŒ‚|๐Ÿ˜) +* ยท16f132b (โŒ‚|๐Ÿ˜) โ–บF, โ–บG, โ–บS1 +* ยท917b9da (โŒ‚|๐Ÿ˜) โ–บD, โ–บE +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บA, โ–บB, โ–บC, โ–บmain, โ–บorigin/main <> origin/main +layout: + materialized parents: 298d938: fafd9d0 fafd9d0 16f132b + empty chain anchors: fafd9d0 fafd9d0 16f132b^ 917b9da^ +"#]] + ); + + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on fafd9d0 {1} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {2} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:S1 on fafd9d0 {3} + โ”œโ”€โ”€ ๐Ÿ“™:S1 + โ”œโ”€โ”€ ๐Ÿ“™:G + โ”œโ”€โ”€ ๐Ÿ“™:F + โ”‚ โ””โ”€โ”€ ยท16f132b (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ๐Ÿ“™:E + โ””โ”€โ”€ ยท917b9da (๐Ÿ˜๏ธ) + +"#]] + ); + + let ws = Workspace::from_tip( + id, + ref_name.clone(), + &*meta, + project_meta(&*meta), + standard_options(), + )? + .validated()?; + // This should look the same as before, despite the starting position. + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ยท298d938 (โŒ‚|๐Ÿ˜) +* ๐Ÿ‘‰ยท16f132b (โŒ‚|๐Ÿ˜) โ–บF, โ–บG, โ–บS1 +* ยท917b9da (โŒ‚|๐Ÿ˜) โ–บD, โ–บE +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บA, โ–บB, โ–บC, โ–บmain, โ–บorigin/main <> origin/main +layout: + empty chain anchors: fafd9d0 fafd9d0 16f132b^ 917b9da^ +"#]] + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on fafd9d0 {1} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {2} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:S1 on fafd9d0 {3} + โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:S1 + โ”œโ”€โ”€ ๐Ÿ“™:G + โ”œโ”€โ”€ ๐Ÿ“™:F + โ”‚ โ””โ”€โ”€ ยท16f132b (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ๐Ÿ“™:E + โ””โ”€โ”€ ยท917b9da (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +#[test] +fn just_init_with_branches_complex() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/just-init-with-branches")?; + + // A combination of dependent and independent stacks. + add_stack_with_segments(&mut meta, 0, "C", StackState::InWorkspace, &["B"]); + add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &[]); + add_stack_with_segments(&mut meta, 2, "D", StackState::InWorkspace, &["E"]); + add_stack_with_segments(&mut meta, 3, "F", StackState::InWorkspace, &[]); + + let (id, ref_name) = id_at(&repo, "gitbutler/workspace"); + let ws = Workspace::from_tip( + id, + ref_name.clone(), + &*meta, + project_meta(&*meta), + standard_options(), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บA, โ–บB, โ–บC, โ–บD, โ–บE, โ–บF, โ–บmain[๐ŸŒณ], โ–บorigin/main <> origin/main +layout: + empty chain anchors: fafd9d0 fafd9d0 fafd9d0 fafd9d0 +"#]] + ); + + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on fafd9d0 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:D on fafd9d0 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:D +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:E +โ””โ”€โ”€ โ‰ก๐Ÿ“™:F on fafd9d0 {3} + โ””โ”€โ”€ ๐Ÿ“™:F + +"#]] + ); + + let (id, ref_name) = id_at(&repo, "C"); + let ws = Workspace::from_tip( + id, + ref_name.clone(), + &*meta, + project_meta(&*meta), + standard_options(), + )? + .validated()?; + // The entrypoint shouldn't affect the outcome (even though it changes the initial segmentation). + // However, as the segment it's on is integrated, it's not considered to be part of the workspace. + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บA, โ–บB, โ–บC, โ–บD, โ–บE, โ–บF, โ–บmain[๐ŸŒณ], โ–บorigin/main <> origin/main +layout: + empty chain anchors: fafd9d0 fafd9d0 fafd9d0 fafd9d0 +"#]] + ); + + // We should see the same stacks as we did before, just with a different entrypoint. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:C on fafd9d0 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:C +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:D on fafd9d0 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:D +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:E +โ””โ”€โ”€ โ‰ก๐Ÿ“™:F on fafd9d0 {3} + โ””โ”€โ”€ ๐Ÿ“™:F + +"#]] + ); + Ok(()) +} + +#[test] +fn proper_remote_ahead() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/proper-remote-ahead")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 9bcd3af (HEAD -> gitbutler/workspace) GitButler Workspace Commit +| * ca7baa7 (origin/main) only-remote-02 +| * 7ea1468 only-remote-01 +|/ +* 998eae6 (main) shared +* fafd9d0 init + +"#]] + ); + + // Remote segments are picked up automatically and traversed - they never take ownership of already assigned commits. + add_workspace(&mut meta); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท9bcd3af (โŒ‚|๐Ÿ˜) +โ”‚ * ๐ŸŸฃca7baa7 (โœ“) โ–บorigin/main +โ”‚ * ๐ŸŸฃ7ea1468 (โœ“) +โ”œโ”€โ•ฏ +* ยท998eae6 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain <> origin/main +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) +layout: + materialized parents: 9bcd3af: 998eae6 +"#]] + ); + + // Everything in the workspace is integrated, thus it's empty. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ2 on 998eae6 + +"#]] + ); + + let (id, ref_name) = id_at(&repo, "main"); + // The integration branch can be in the workspace and be checked out. + let ws = Workspace::from_tip( + id, + Some(ref_name), + &*meta, + project_meta(&*meta), + standard_options(), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ยท9bcd3af (โŒ‚|๐Ÿ˜) +โ”‚ * ๐ŸŸฃca7baa7 (โœ“) โ–บorigin/main +โ”‚ * ๐ŸŸฃ7ea1468 (โœ“) +โ”œโ”€โ•ฏ +* ๐Ÿ‘‰ยท998eae6 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain <> origin/main +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) +"#]] + ); + + // If it's checked out, we must show the branch container, but it's not part of the + // managed workspace. The target context is preserved and integrated local/base commits + // are pruned, leaving only target-side commits ahead of the stored target. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +โŒ‚:main <> โœ“refs/remotes/origin/mainโ‡ฃ2 +โ””โ”€โ”€ โ‰ก:main <> origin/mainโ‡ฃ2 {1} + โ””โ”€โ”€ :main <> origin/mainโ‡ฃ2 + โ”œโ”€โ”€ ๐ŸŸฃca7baa7 (โœ“) + โ””โ”€โ”€ ๐ŸŸฃ7ea1468 (โœ“) + +"#]] + ); + Ok(()) +} + +#[test] +fn deduced_remote_ahead() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/deduced-remote-ahead")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 8b39ce4 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 9d34471 (A) A2 +* 5b89c71 A1 +| * 3ea1a8f (push-remote/A, origin/A) only-remote-02 +| * 9c50f71 only-remote-01 +| * 2cfbb79 merge +|/| +| * e898cd0 feat-on-remote +|/ +* 998eae6 shared +* fafd9d0 (main) init + +"#]] + ); + + // Remote segments are picked up automatically and traversed - they never take ownership of already assigned commits. + add_workspace(&mut meta); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท8b39ce4 (โŒ‚|๐Ÿ˜) +* ยท9d34471 (โŒ‚|๐Ÿ˜) โ–บA <> origin/A +* ยท5b89c71 (โŒ‚|๐Ÿ˜) +โ”‚ * ๐ŸŸฃ3ea1a8f โ–บorigin/A, โ–บpush-remote/A +โ”‚ * ๐ŸŸฃ9c50f71 +โ”‚ * ๐ŸŸฃ2cfbb79 +โ•ญโ”€โ”ค +โ”‚ * ๐ŸŸฃe898cd0 +โ”œโ”€โ•ฏ +* ยท998eae6 (โŒ‚|๐Ÿ˜) +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) โ–บmain +layout: + materialized parents: 8b39ce4: 9d34471 +"#]] + ); + // There is no target branch, so nothing is integrated, and `main` shows up. + // It's not special. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:A <> origin/Aโ‡ก2โ‡ฃ4 + โ”œโ”€โ”€ :A <> origin/Aโ‡ก2โ‡ฃ4 + โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ3ea1a8f + โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ9c50f71 + โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ2cfbb79 + โ”‚ โ”œโ”€โ”€ ๐ŸŸฃe898cd0 + โ”‚ โ”œโ”€โ”€ ยท9d34471 (๐Ÿ˜๏ธ) + โ”‚ โ”œโ”€โ”€ ยท5b89c71 (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ โ„๏ธ998eae6 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :main + โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) + +"#]] + ); + + let id = id_by_rev(&repo, ":/init"); + let ws = Workspace::from_tip(id, None, &*meta, project_meta(&*meta), standard_options())?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ยท8b39ce4 (โŒ‚|๐Ÿ˜) +* ยท9d34471 (โŒ‚|๐Ÿ˜) โ–บA <> origin/A +* ยท5b89c71 (โŒ‚|๐Ÿ˜) +โ”‚ * ๐ŸŸฃ3ea1a8f โ–บorigin/A, โ–บpush-remote/A +โ”‚ * ๐ŸŸฃ9c50f71 +โ”‚ * ๐ŸŸฃ2cfbb79 +โ•ญโ”€โ”ค +โ”‚ * ๐ŸŸฃe898cd0 +โ”œโ”€โ•ฏ +* ยท998eae6 (โŒ‚|๐Ÿ˜) +* ๐Ÿ‘‰๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) โ–บmain +"#]] + ); + // The whole workspace is visible, but it's clear where the entrypoint is. + // As there is no target ref, `main` shows up. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:A <> origin/Aโ‡ก2โ‡ฃ4 + โ”œโ”€โ”€ :A <> origin/Aโ‡ก2โ‡ฃ4 + โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ3ea1a8f + โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ9c50f71 + โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ2cfbb79 + โ”‚ โ”œโ”€โ”€ ๐ŸŸฃe898cd0 + โ”‚ โ”œโ”€โ”€ ยท9d34471 (๐Ÿ˜๏ธ) + โ”‚ โ”œโ”€โ”€ ยท5b89c71 (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ โ„๏ธ998eae6 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ๐Ÿ‘‰:main + โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) + +"#]] + ); + + // When the push-remote is configured, it overrides the remote we use for listing, even if a fetch remote is available. + let mut ws = meta.workspace(WORKSPACE_REF_NAME.try_into().expect("valid workspace ref"))?; + let mut pm = ws.project_meta(); + pm.push_remote = Some("push-remote".into()); + ws.set_project_meta(pm); + meta.set_workspace(&ws)?; + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท8b39ce4 (โŒ‚|๐Ÿ˜) +* ยท9d34471 (โŒ‚|๐Ÿ˜) โ–บA <> push-remote/A +* ยท5b89c71 (โŒ‚|๐Ÿ˜) +โ”‚ * ๐ŸŸฃ3ea1a8f โ–บorigin/A, โ–บpush-remote/A +โ”‚ * ๐ŸŸฃ9c50f71 +โ”‚ * ๐ŸŸฃ2cfbb79 +โ•ญโ”€โ”ค +โ”‚ * ๐ŸŸฃe898cd0 +โ”œโ”€โ•ฏ +* ยท998eae6 (โŒ‚|๐Ÿ˜) +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) โ–บmain +layout: + materialized parents: 8b39ce4: 9d34471 +"#]] + ); + + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:A <> push-remote/Aโ‡ก2โ‡ฃ4 + โ”œโ”€โ”€ :A <> push-remote/Aโ‡ก2โ‡ฃ4 + โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ3ea1a8f + โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ9c50f71 + โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ2cfbb79 + โ”‚ โ”œโ”€โ”€ ๐ŸŸฃe898cd0 + โ”‚ โ”œโ”€โ”€ ยท9d34471 (๐Ÿ˜๏ธ) + โ”‚ โ”œโ”€โ”€ ยท5b89c71 (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ โ„๏ธ998eae6 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :main + โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +#[test] +fn stacked_rebased_remotes() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/remote-includes-another-remote")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 682be32 (origin/B) B +* e29c23d (origin/A) A +| * 7786959 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +| * 312f819 (B) B +| * e255adc (A) A +|/ +* fafd9d0 (origin/main, main) init + +"#]] + ); + + // This is like remotes have been stacked and are completely rebased so they differ from their local + // commits. This also means they include each other. + add_workspace(&mut meta); + let ws = Workspace::from_head( + &repo, + &*meta, + but_core::ref_metadata::ProjectMeta::default(), + standard_options(), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท7786959 (โŒ‚|๐Ÿ˜) +* ยท312f819 (โŒ‚|๐Ÿ˜) โ–บB +* ยทe255adc (โŒ‚|๐Ÿ˜) โ–บA +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) โ–บmain, โ–บorigin/main <> origin/main +layout: + materialized parents: 7786959: 312f819 +"#]] + ); + // It's worth noting that we avoid double-listing remote commits that are also + // directly owned by another remote segment. + // they have to be considered as something relevant to the branch history. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:B + โ”œโ”€โ”€ :B + โ”‚ โ””โ”€โ”€ ยท312f819 (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ :A + โ”‚ โ””โ”€โ”€ ยทe255adc (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :main <> origin/main + โ””โ”€โ”€ โ„๏ธfafd9d0 (๐Ÿ˜๏ธ) + +"#]] + ); + + // The result is the same when changing the entrypoint. + let (id, name) = id_at(&repo, "A"); + let ws = Workspace::from_tip(id, name, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ยท7786959 (โŒ‚|๐Ÿ˜) +* ยท312f819 (โŒ‚|๐Ÿ˜) โ–บB <> origin/B +* ๐Ÿ‘‰ยทe255adc (โŒ‚|๐Ÿ˜) โ–บA <> origin/A +โ”‚ * ๐ŸŸฃ682be32 โ–บorigin/B +โ”‚ * ๐ŸŸฃe29c23d โ–บorigin/A +โ”œโ”€โ•ฏ +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บorigin/main <> origin/main +"#]] + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:B <> origin/Bโ‡ก1โ‡ฃ1 on fafd9d0 + โ”œโ”€โ”€ :B <> origin/Bโ‡ก1โ‡ฃ1 + โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ682be32 + โ”‚ โ””โ”€โ”€ ยท312f819 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ๐Ÿ‘‰:A <> origin/Aโ‡ก1โ‡ฃ1 + โ”œโ”€โ”€ ๐ŸŸฃe29c23d + โ””โ”€โ”€ ยทe255adc (๐Ÿ˜๏ธ) + +"#]] + ); + snapbox::assert_data_eq!( + format!("{:#?}", ws.statistics()).as_str(), + snapbox::str![[r#" +CommitGraphStatistics { + commits: 6, + edges_connected: 5, + edges_cut: 0, + refs: 7, + commits_at_tip: 2, + commits_at_bottom: 1, + commits_in_workspace: 4, + commits_integrated: 1, + commits_not_in_remote: 4, + layout_refs: Some( + 7, + ), + hard_limit_hit: false, + entrypoint: Some( + Sha1(e255adcd9be0ffabbed19f4ef85c338e54a34376), + ), +} +"#]] + ); + Ok(()) +} + +#[test] +fn target_with_remote_on_stack_tip() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/local-target-ahead-and-on-stack-tip")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* dd0cca8 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* e255adc (main, A) A +* fafd9d0 (origin/main) init + +"#]] + ); + add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &[]); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทdd0cca8 (โŒ‚|๐Ÿ˜) +* ยทe255adc (โŒ‚|๐Ÿ˜) โ–บA, โ–บmain <> origin/main +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บorigin/main +layout: + materialized parents: dd0cca8: e255adc + empty chain anchors: e255adc^ +"#]] + ); + + // The main branch is not present, as it's the target. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยทe255adc (๐Ÿ˜๏ธ) โ–บmain + +"#]] + ); + + // But mention it if it's in the workspace. It should retain order. + add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &["main"]); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ๐Ÿ“™:main <> origin/mainโ‡ก1 + โ””โ”€โ”€ ยทe255adc (๐Ÿ˜๏ธ) + +"#]] + ); + + // But mention it if it's in the workspace. It should retain order - inverting the order is fine. + add_stack_with_segments(&mut meta, 1, "main", StackState::InWorkspace, &["A"]); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:main <> origin/main on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:main <> origin/main + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยทe255adc (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +#[test] +fn disambiguate_by_remote() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/disambiguate-by-remote")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* e30f90c (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 2173153 (origin/ambiguous-C, origin/C, ambiguous-C, C) C +| * ac24e74 (origin/B) remote-of-B +|/ +* 312f819 (ambiguous-B, B) B +* e255adc (origin/A, ambiguous-A, A) A +* fafd9d0 (origin/main, main) init + +"#]] + ); + + add_workspace(&mut meta); + // As remote connections point at segments, if these stream back into their local tracking + // branch, and the segment is unnamed, and the first commit is ambiguous name-wise, we + // use the remote tracking branch to disambiguate the segment. After all, it's beneficial + // to have properly wired segments. + // Note that this is more complicated if the local tracking branch is also advanced, but + // this is something to improve when workspace-less operation becomes a thing *and* we + // need to get better as disambiguation. + // The target branch is actually counted as remote, but it doesn't come through here as + // it steals the commit from `main`. This should be fine. + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทe30f90c (โŒ‚|๐Ÿ˜) +* ยท2173153 (โŒ‚|๐Ÿ˜) โ–บC, โ–บambiguous-C, โ–บorigin/C, โ–บorigin/ambiguous-C <> origin/C, origin/ambiguous-C +โ”‚ * ๐ŸŸฃac24e74 โ–บorigin/B +โ”œโ”€โ•ฏ +* ยท312f819 (โŒ‚|๐Ÿ˜) โ–บB, โ–บambiguous-B <> origin/B +* ยทe255adc (โŒ‚|๐Ÿ˜) โ–บA, โ–บambiguous-A, โ–บorigin/A <> origin/A +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บorigin/main <> origin/main +layout: + materialized parents: e30f90c: 2173153 +"#]] + ); + + assert!( + { + let cg = ws.commit_graph(); + cg.commit_ids().all(|id| !cg.has_cut_parents(id)) + }, + "a fully realized graph" + ); + // An anonymous segment to start with is alright, and can always happen for other situations as well. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:anon: on fafd9d0 + โ”œโ”€โ”€ :anon: + โ”‚ โ””โ”€โ”€ ยท2173153 (๐Ÿ˜๏ธ) โ–บC, โ–บambiguous-C + โ”œโ”€โ”€ :B <> origin/Bโ‡ฃ1 + โ”‚ โ”œโ”€โ”€ ๐ŸŸฃac24e74 + โ”‚ โ””โ”€โ”€ โ„๏ธ312f819 (๐Ÿ˜๏ธ) โ–บambiguous-B + โ””โ”€โ”€ :A <> origin/A + โ””โ”€โ”€ โ„๏ธe255adc (๐Ÿ˜๏ธ) โ–บambiguous-A + +"#]] + ); + + // If 'C' is in the workspace, it's naturally disambiguated. + add_stack_with_segments(&mut meta, 0, "C", StackState::InWorkspace, &[]); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทe30f90c (โŒ‚|๐Ÿ˜) +* ยท2173153 (โŒ‚|๐Ÿ˜) โ–บC, โ–บambiguous-C, โ–บorigin/C, โ–บorigin/ambiguous-C <> origin/C, origin/ambiguous-C +โ”‚ * ๐ŸŸฃac24e74 โ–บorigin/B +โ”œโ”€โ•ฏ +* ยท312f819 (โŒ‚|๐Ÿ˜) โ–บB, โ–บambiguous-B <> origin/B +* ยทe255adc (โŒ‚|๐Ÿ˜) โ–บA, โ–บambiguous-A, โ–บorigin/A <> origin/A +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บorigin/main <> origin/main +layout: + materialized parents: e30f90c: 2173153 + empty chain anchors: 2173153^ +"#]] + ); + // And because `C` is in the workspace data, its data is denoted. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C <> origin/C on fafd9d0 {0} + โ”œโ”€โ”€ ๐Ÿ“™:C <> origin/C + โ”‚ โ””โ”€โ”€ โ„๏ธ2173153 (๐Ÿ˜๏ธ) โ–บambiguous-C + โ”œโ”€โ”€ :B <> origin/Bโ‡ฃ1 + โ”‚ โ”œโ”€โ”€ ๐ŸŸฃac24e74 + โ”‚ โ””โ”€โ”€ โ„๏ธ312f819 (๐Ÿ˜๏ธ) โ–บambiguous-B + โ””โ”€โ”€ :A <> origin/A + โ””โ”€โ”€ โ„๏ธe255adc (๐Ÿ˜๏ธ) โ–บambiguous-A + +"#]] + ); + Ok(()) +} + +#[test] +fn integrated_tips_stop_early_if_remote_is_not_configured() -> anyhow::Result<()> { + let (repo, mut meta) = + read_only_in_memory_scenario("ws/two-segments-one-integrated-without-remote")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 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 +| | * 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 + +"#]] + .raw() + ); + + add_workspace(&mut meta); + // We can abort early if there is only integrated commits left, but also if there is *no remote setup*. + // We also abort integrated named segments early, unless these are named as being part of the + // workspace - here `A` is cut off. + // Without remote, the traversal can't setup `main` as target for the workspace entrypoint to find. + let ws = Workspace::from_head( + &repo, + &*meta, + but_core::ref_metadata::ProjectMeta::default(), + standard_options(), + )? + .validated()?; + let cg = ws.commit_graph(); + assert!(cg.commit_ids().all(|id| !cg.has_cut_parents(id))); + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท4077353 (โŒ‚|๐Ÿ˜) +* ยท6b1a13b (โŒ‚|๐Ÿ˜) โ–บB +* ยท03ad472 (โŒ‚|๐Ÿ˜) +* ยท79bbb29 (โŒ‚|๐Ÿ˜) โ–บA +* ยทfc98174 (โŒ‚|๐Ÿ˜) +* ยทa381df5 (โŒ‚|๐Ÿ˜) +* ยท777b552 (โŒ‚|๐Ÿ˜) +* ยทce4a760 (โŒ‚|๐Ÿ˜) +โ”œโ”€โ•ฎ +โ”‚ * ยทfea59b5 (โŒ‚|๐Ÿ˜) โ–บA-feat +โ”‚ * ยท4deea74 (โŒ‚|๐Ÿ˜) +โ”œโ”€โ•ฏ +* ยท01d0e1e (โŒ‚|๐Ÿ˜) +* ยท4b3e5a8 (โŒ‚|๐Ÿ˜) โ–บmain +* ยท34d0715 (โŒ‚|๐Ÿ˜) +* ๐Ÿยทeb5f731 (โŒ‚|๐Ÿ˜) +layout: + materialized parents: 4077353: 6b1a13b +"#]] + ); + // It's true that `A` is fully integrated so it isn't displayed. so from a workspace-perspective + // it's the right answer. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:B + โ”œโ”€โ”€ :B + โ”‚ โ”œโ”€โ”€ ยท6b1a13b (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ ยท03ad472 (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ :A + โ”‚ โ”œโ”€โ”€ ยท79bbb29 (๐Ÿ˜๏ธ) + โ”‚ โ”œโ”€โ”€ ยทfc98174 (๐Ÿ˜๏ธ) + โ”‚ โ”œโ”€โ”€ ยทa381df5 (๐Ÿ˜๏ธ) + โ”‚ โ”œโ”€โ”€ ยท777b552 (๐Ÿ˜๏ธ) + โ”‚ โ”œโ”€โ”€ ยทce4a760 (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ ยท01d0e1e (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :main + โ”œโ”€โ”€ ยท4b3e5a8 (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ ยท34d0715 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ยทeb5f731 (๐Ÿ˜๏ธ) + +"#]] + ); + + add_stack_with_segments(&mut meta, 0, "B", StackState::InWorkspace, &["A"]); + // ~~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. + // And since it's integrated, traversal is stopped without convergence. + // We see more though as we add workspace segments immediately. + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท4077353 (โŒ‚|๐Ÿ˜) +* ยท6b1a13b (โŒ‚|๐Ÿ˜) โ–บB +* ยท03ad472 (โŒ‚|๐Ÿ˜) +โ”‚ * ๐ŸŸฃd0df794 (โœ“) โ–บorigin/main +โ”‚ * ๐ŸŸฃ09c6e08 (โœ“) +โ”‚ * ๐ŸŸฃ7b9f260 (โœ“) +โ•ญโ”€โ”ค +โ”‚ * ๐ŸŸฃ4b3e5a8 (โœ“) โ–บmain <> origin/main +โ”‚ * ๐ŸŸฃ34d0715 (โœ“) +โ”‚ * ๐Ÿ๐ŸŸฃeb5f731 (โœ“) +* ยท79bbb29 (โŒ‚|๐Ÿ˜|โœ“) โ–บA +* ยทfc98174 (โŒ‚|๐Ÿ˜|โœ“) +* ยทa381df5 (โŒ‚|๐Ÿ˜|โœ“) +* ยท777b552 (โŒ‚|๐Ÿ˜|โœ“) +* โœ‚ยทce4a760 (โŒ‚|๐Ÿ˜|โœ“) +layout: + materialized parents: 4077353: 6b1a13b + empty chain anchors: 6b1a13b^ +"#]] + ); + // `A` is integrated, hence it's not shown. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ6 on 79bbb29 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 79bbb29 {0} + โ””โ”€โ”€ ๐Ÿ“™:B + โ”œโ”€โ”€ ยท6b1a13b (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ยท03ad472 (๐Ÿ˜๏ธ) + +"#]] + ); + + // The limit is effective for integrated workspaces branches, and it doesn't unnecessarily + // prolong the traversal once the all tips are known to be integrated. + let ws = Workspace::from_head( + &repo, + &*meta, + project_meta(&*meta), + standard_options().with_limit_hint(1), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท4077353 (โŒ‚|๐Ÿ˜) +* ยท6b1a13b (โŒ‚|๐Ÿ˜) โ–บB +* ยท03ad472 (โŒ‚|๐Ÿ˜) +โ”‚ * ๐ŸŸฃd0df794 (โœ“) โ–บorigin/main +โ”‚ * ๐ŸŸฃ09c6e08 (โœ“) +โ”‚ * ๐ŸŸฃ7b9f260 (โœ“) +โ•ญโ”€โ”ค +โ”‚ * ๐ŸŸฃ4b3e5a8 (โœ“) โ–บmain <> origin/main +โ”‚ * ๐ŸŸฃ34d0715 (โœ“) +โ”‚ * ๐Ÿ๐ŸŸฃeb5f731 (โœ“) +* ยท79bbb29 (โŒ‚|๐Ÿ˜|โœ“) โ–บA +* ยทfc98174 (โŒ‚|๐Ÿ˜|โœ“) +* ยทa381df5 (โŒ‚|๐Ÿ˜|โœ“) +* โœ‚ยท777b552 (โŒ‚|๐Ÿ˜|โœ“) +layout: + materialized parents: 4077353: 6b1a13b + empty chain anchors: 6b1a13b^ +"#]] + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ6 on 79bbb29 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 79bbb29 {0} + โ””โ”€โ”€ ๐Ÿ“™:B + โ”œโ”€โ”€ ยท6b1a13b (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ยท03ad472 (๐Ÿ˜๏ธ) + +"#]] + ); + + meta.data_mut().branches.clear(); + add_workspace(&mut meta); + // When looking from an integrated branch within the workspace, but without limit, + // the (lack of) limit is respected. + // When the entrypoint starts on an integrated commit, the 'all-tips-are-integrated' condition doesn't + // kick in anymore. + let (id, ref_name) = id_at(&repo, "A"); + let ws = Workspace::from_tip( + id, + ref_name.clone(), + &*meta, + project_meta(&*meta), + standard_options(), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ยท4077353 (โŒ‚|๐Ÿ˜) +* ยท6b1a13b (โŒ‚|๐Ÿ˜) โ–บB +* ยท03ad472 (โŒ‚|๐Ÿ˜) +โ”‚ * ๐ŸŸฃd0df794 (โœ“) โ–บorigin/main +โ”‚ * ๐ŸŸฃ09c6e08 (โœ“) +โ”‚ * ๐ŸŸฃ7b9f260 (โœ“) +โ•ญโ”€โ”ค +* โ”‚ ๐Ÿ‘‰ยท79bbb29 (โŒ‚|๐Ÿ˜|โœ“) โ–บA +* โ”‚ ยทfc98174 (โŒ‚|๐Ÿ˜|โœ“) +* โ”‚ ยทa381df5 (โŒ‚|๐Ÿ˜|โœ“) +* โ”‚ ยท777b552 (โŒ‚|๐Ÿ˜|โœ“) +* โ”‚ ยทce4a760 (โŒ‚|๐Ÿ˜|โœ“) +โ”œโ”€โ”€โ”€โ•ฎ +โ”‚ โ”‚ * ยทfea59b5 (โŒ‚|๐Ÿ˜|โœ“) โ–บA-feat +โ”‚ โ”‚ * ยท4deea74 (โŒ‚|๐Ÿ˜|โœ“) +โ”œโ”€โ”€โ”€โ•ฏ +* โ”‚ ยท01d0e1e (โŒ‚|๐Ÿ˜|โœ“) +โ”œโ”€โ•ฏ +* ยท4b3e5a8 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain <> origin/main +* ยท34d0715 (โŒ‚|๐Ÿ˜|โœ“) +* ๐Ÿยทeb5f731 (โŒ‚|๐Ÿ˜|โœ“) +"#]] + ); + // The entrypoint branch is downgraded to a single-branch view with target context + // preserved. All commits on this branch are integrated, so the branch container remains + // but its commit list is pruned. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +โŒ‚:A <> โœ“refs/remotes/origin/mainโ‡ฃ3 +โ””โ”€โ”€ โ‰ก:A on 4b3e5a8 {1} + โ””โ”€โ”€ :A + +"#]] + ); + + let ws = Workspace::from_tip( + id, + ref_name, + &*meta, + project_meta(&*meta), + standard_options().with_limit_hint(1), + )? + .validated()?; + // It's still getting quite far despite the limit due to other heads searching for their goals, + // but also ends traversal early. + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ยท4077353 (โŒ‚|๐Ÿ˜) +* ยท6b1a13b (โŒ‚|๐Ÿ˜) โ–บB +* ยท03ad472 (โŒ‚|๐Ÿ˜) +โ”‚ * ๐ŸŸฃd0df794 (โœ“) โ–บorigin/main +โ”‚ * ๐ŸŸฃ09c6e08 (โœ“) +โ”‚ * ๐ŸŸฃ7b9f260 (โœ“) +โ•ญโ”€โ”ค +โ”‚ * ๐ŸŸฃ4b3e5a8 (โœ“) โ–บmain <> origin/main +โ”‚ * ๐ŸŸฃ34d0715 (โœ“) +โ”‚ * ๐Ÿ๐ŸŸฃeb5f731 (โœ“) +* ๐Ÿ‘‰ยท79bbb29 (โŒ‚|๐Ÿ˜|โœ“) โ–บA +* ยทfc98174 (โŒ‚|๐Ÿ˜|โœ“) +* ยทa381df5 (โŒ‚|๐Ÿ˜|โœ“) +* โœ‚ยท777b552 (โŒ‚|๐Ÿ˜|โœ“) +"#]] + ); + // Because the branch is integrated, the surrounding workspace isn't shown. The downgraded + // branch view keeps target context and prunes the integrated commits. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +โŒ‚:A <> โœ“refs/remotes/origin/mainโ‡ฃ6 +โ””โ”€โ”€ โ‰ก:A {1} + โ””โ”€โ”€ :A + +"#]] + ); + + // See what happens with an out-of-workspace HEAD and an arbitrary extra target. + let (id, _ref_name) = id_at(&repo, "origin/main"); + let ws = Workspace::from_tip( + id, + None, + &*meta, + project_meta(&*meta), + standard_options_with_extra_target(&repo, "gitbutler/workspace"), + )? + .validated()?; + // It keeps the tip-settings of the workspace it setup by itself, and doesn't override this + // with the extra-target settings. + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทd0df794 (โŒ‚|โœ“) โ–บorigin/main +* ยท09c6e08 (โŒ‚|โœ“) +* ยท7b9f260 (โŒ‚|โœ“) +โ”œโ”€โ•ฎ +โ”‚ โ”‚ * ยท4077353 (โŒ‚|๐Ÿ˜) +โ”‚ โ”‚ * ยท6b1a13b (โŒ‚|๐Ÿ˜) โ–บB +โ”‚ โ”‚ * ยท03ad472 (โŒ‚|๐Ÿ˜) +โ”‚ โ”œโ”€โ•ฏ +โ”‚ * ยท79bbb29 (โŒ‚|๐Ÿ˜|โœ“) โ–บA +โ”‚ * ยทfc98174 (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ * ยทa381df5 (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ * ยท777b552 (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ * ยทce4a760 (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ โ”œโ”€โ•ฎ +โ”‚ โ”‚ * ยทfea59b5 (โŒ‚|๐Ÿ˜|โœ“) โ–บA-feat +โ”‚ โ”‚ * ยท4deea74 (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ โ”œโ”€โ•ฏ +โ”‚ * ยท01d0e1e (โŒ‚|๐Ÿ˜|โœ“) +โ”œโ”€โ•ฏ +* ยท4b3e5a8 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain <> origin/main +* ยท34d0715 (โŒ‚|๐Ÿ˜|โœ“) +* ๐Ÿยทeb5f731 (โŒ‚|๐Ÿ˜|โœ“) +"#]] + ); + + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +โŒ‚:DETACHED <> โœ“refs/remotes/origin/mainโ‡ฃ3 on 79bbb29 +โ””โ”€โ”€ โ‰ก:anon: on 4b3e5a8 {1} + โ””โ”€โ”€ :anon: + โ”œโ”€โ”€ ยทd0df794 (โœ“) + โ”œโ”€โ”€ ยท09c6e08 (โœ“) + โ””โ”€โ”€ ยท7b9f260 (โœ“) + +"#]] + ); + + // However, when choosing an initially unknown branch, it will get the extra target tip settings. + let ws = Workspace::from_tip( + id, + None, + &*meta, + project_meta(&*meta), + standard_options_with_extra_target(&repo, "B"), + )? + .validated()?; + // For now we don't do anything to limit the each in single-branch mode using extra-targets. + // Thanks to the limit-transplant we get to discover more of the workspace. + // TODO(extra-target): make it work so they limit single branches even, but it's a special case + // as we can't have remotes here. + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทd0df794 (โŒ‚|โœ“) โ–บorigin/main +* ยท09c6e08 (โŒ‚|โœ“) +* ยท7b9f260 (โŒ‚|โœ“) +โ”œโ”€โ•ฎ +โ”‚ โ”‚ * ยท4077353 (โŒ‚|๐Ÿ˜) +โ”‚ โ”‚ * ยท6b1a13b (โŒ‚|๐Ÿ˜|โœ“) โ–บB +โ”‚ โ”‚ * ยท03ad472 (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ โ”œโ”€โ•ฏ +โ”‚ * ยท79bbb29 (โŒ‚|๐Ÿ˜|โœ“) โ–บA +โ”‚ * ยทfc98174 (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ * ยทa381df5 (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ * ยท777b552 (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ * ยทce4a760 (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ โ”œโ”€โ•ฎ +โ”‚ โ”‚ * ยทfea59b5 (โŒ‚|๐Ÿ˜|โœ“) โ–บA-feat +โ”‚ โ”‚ * ยท4deea74 (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ โ”œโ”€โ•ฏ +โ”‚ * ยท01d0e1e (โŒ‚|๐Ÿ˜|โœ“) +โ”œโ”€โ•ฏ +* ยท4b3e5a8 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain <> origin/main +* ยท34d0715 (โŒ‚|๐Ÿ˜|โœ“) +* ๐Ÿยทeb5f731 (โŒ‚|๐Ÿ˜|โœ“) +"#]] + ); + + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +โŒ‚:DETACHED <> โœ“refs/remotes/origin/mainโ‡ฃ3 on 79bbb29 +โ””โ”€โ”€ โ‰ก:anon: on 4b3e5a8 {1} + โ””โ”€โ”€ :anon: + โ”œโ”€โ”€ ยทd0df794 (โœ“) + โ”œโ”€โ”€ ยท09c6e08 (โœ“) + โ””โ”€โ”€ ยท7b9f260 (โœ“) + +"#]] + ); + + Ok(()) +} + +#[test] +fn integrated_tips_do_not_stop_early() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/two-segments-one-integrated")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 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 +| | * 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 + +"#]] + .raw() + ); + + add_workspace(&mut meta); + // Thanks to the remote `main` is searched for by the entrypoint. + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท4077353 (โŒ‚|๐Ÿ˜) +* ยท6b1a13b (โŒ‚|๐Ÿ˜) โ–บB +* ยท03ad472 (โŒ‚|๐Ÿ˜) +โ”‚ * ๐ŸŸฃd0df794 (โœ“) โ–บorigin/main +โ”‚ * ๐ŸŸฃ09c6e08 (โœ“) +โ”‚ * ๐ŸŸฃ7b9f260 (โœ“) +โ•ญโ”€โ”ค +* โ”‚ ยท79bbb29 (โŒ‚|๐Ÿ˜|โœ“) โ–บA +* โ”‚ ยทfc98174 (โŒ‚|๐Ÿ˜|โœ“) +* โ”‚ ยทa381df5 (โŒ‚|๐Ÿ˜|โœ“) +* โ”‚ ยท777b552 (โŒ‚|๐Ÿ˜|โœ“) +* โ”‚ ยทce4a760 (โŒ‚|๐Ÿ˜|โœ“) +โ”œโ”€โ”€โ”€โ•ฎ +โ”‚ โ”‚ * ยทfea59b5 (โŒ‚|๐Ÿ˜|โœ“) โ–บA-feat +โ”‚ โ”‚ * ยท4deea74 (โŒ‚|๐Ÿ˜|โœ“) +โ”œโ”€โ”€โ”€โ•ฏ +* โ”‚ ยท01d0e1e (โŒ‚|๐Ÿ˜|โœ“) +โ”œโ”€โ•ฏ +* ยท4b3e5a8 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain <> origin/main +* ยท34d0715 (โŒ‚|๐Ÿ˜|โœ“) +* ๐Ÿยทeb5f731 (โŒ‚|๐Ÿ˜|โœ“) +layout: + materialized parents: 4077353: 6b1a13b +"#]] + ); + + // This search discovers the whole workspace, without the integrated one. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ3 on 79bbb29 +โ””โ”€โ”€ โ‰ก:B on 79bbb29 + โ””โ”€โ”€ :B + โ”œโ”€โ”€ ยท6b1a13b (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ยท03ad472 (๐Ÿ˜๏ธ) + +"#]] + ); + + // However, we can specify an additional/old target segment to show integrated portions as well. + let ws = Workspace::from_head( + &repo, + &*meta, + project_meta(&*meta), + standard_options_with_extra_target(&repo, "main"), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ3 on 4b3e5a8 +โ””โ”€โ”€ โ‰ก:B on 4b3e5a8 + โ”œโ”€โ”€ :B + โ”‚ โ”œโ”€โ”€ ยท6b1a13b (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ ยท03ad472 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :A + โ”œโ”€โ”€ ยท79bbb29 (๐Ÿ˜๏ธ|โœ“) + โ”œโ”€โ”€ ยทfc98174 (๐Ÿ˜๏ธ|โœ“) + โ”œโ”€โ”€ ยทa381df5 (๐Ÿ˜๏ธ|โœ“) + โ”œโ”€โ”€ ยท777b552 (๐Ÿ˜๏ธ|โœ“) + โ”œโ”€โ”€ ยทce4a760 (๐Ÿ˜๏ธ|โœ“) + โ””โ”€โ”€ ยท01d0e1e (๐Ÿ˜๏ธ|โœ“) + +"#]] + ); + + // When looking from an integrated branch within the workspace, and without limit + // the limit isn't respected, and we still know the whole workspace. + let (id, ref_name) = id_at(&repo, "A"); + let ws = Workspace::from_tip( + id, + ref_name.clone(), + &*meta, + project_meta(&*meta), + standard_options(), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ยท4077353 (โŒ‚|๐Ÿ˜) +* ยท6b1a13b (โŒ‚|๐Ÿ˜) โ–บB +* ยท03ad472 (โŒ‚|๐Ÿ˜) +โ”‚ * ๐ŸŸฃd0df794 (โœ“) โ–บorigin/main +โ”‚ * ๐ŸŸฃ09c6e08 (โœ“) +โ”‚ * ๐ŸŸฃ7b9f260 (โœ“) +โ•ญโ”€โ”ค +* โ”‚ ๐Ÿ‘‰ยท79bbb29 (โŒ‚|๐Ÿ˜|โœ“) โ–บA +* โ”‚ ยทfc98174 (โŒ‚|๐Ÿ˜|โœ“) +* โ”‚ ยทa381df5 (โŒ‚|๐Ÿ˜|โœ“) +* โ”‚ ยท777b552 (โŒ‚|๐Ÿ˜|โœ“) +* โ”‚ ยทce4a760 (โŒ‚|๐Ÿ˜|โœ“) +โ”œโ”€โ”€โ”€โ•ฎ +โ”‚ โ”‚ * ยทfea59b5 (โŒ‚|๐Ÿ˜|โœ“) โ–บA-feat +โ”‚ โ”‚ * ยท4deea74 (โŒ‚|๐Ÿ˜|โœ“) +โ”œโ”€โ”€โ”€โ•ฏ +* โ”‚ ยท01d0e1e (โŒ‚|๐Ÿ˜|โœ“) +โ”œโ”€โ•ฏ +* ยท4b3e5a8 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain <> origin/main +* ยท34d0715 (โŒ‚|๐Ÿ˜|โœ“) +* ๐Ÿยทeb5f731 (โŒ‚|๐Ÿ˜|โœ“) +"#]] + ); + + // The entrypoint isn't contained in the managed workspace anymore, so it's a standalone + // single-branch view. Target context is preserved, so integrated commits are pruned while + // the branch container remains visible. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +โŒ‚:A <> โœ“refs/remotes/origin/mainโ‡ฃ3 +โ””โ”€โ”€ โ‰ก:A on 4b3e5a8 {1} + โ””โ”€โ”€ :A + +"#]] + ); + + // When converting to a workspace, we are still aware of the workspace membership as long as + // the lower bound of the workspace includes it. + let ws = Workspace::from_tip( + id, + ref_name.clone(), + &*meta, + project_meta(&*meta), + standard_options_with_extra_target(&repo, "main"), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ3 on 4b3e5a8 +โ””โ”€โ”€ โ‰ก:B on 4b3e5a8 + โ”œโ”€โ”€ :B + โ”‚ โ”œโ”€โ”€ ยท6b1a13b (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ ยท03ad472 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ๐Ÿ‘‰:A + โ”œโ”€โ”€ ยท79bbb29 (๐Ÿ˜๏ธ|โœ“) + โ”œโ”€โ”€ ยทfc98174 (๐Ÿ˜๏ธ|โœ“) + โ”œโ”€โ”€ ยทa381df5 (๐Ÿ˜๏ธ|โœ“) + โ”œโ”€โ”€ ยท777b552 (๐Ÿ˜๏ธ|โœ“) + โ”œโ”€โ”€ ยทce4a760 (๐Ÿ˜๏ธ|โœ“) + โ””โ”€โ”€ ยท01d0e1e (๐Ÿ˜๏ธ|โœ“) + +"#]] + ); + + let (id, ref_name) = id_at(&repo, "main"); + let ws = Workspace::from_tip( + id, + ref_name.clone(), + &*meta, + project_meta(&*meta), + standard_options(), + )? + .validated()?; + // When the branch is below the forkpoint, the workspace also isn't shown anymore. + // The downgraded branch view keeps target context and prunes integrated base commits. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +โŒ‚:main <> โœ“refs/remotes/origin/mainโ‡ฃ3 +โ””โ”€โ”€ โ‰ก:main <> origin/mainโ‡ฃ3 {1} + โ””โ”€โ”€ :main <> origin/mainโ‡ฃ3 + โ”œโ”€โ”€ ๐ŸŸฃd0df794 (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃ09c6e08 (โœ“) + โ””โ”€โ”€ ๐ŸŸฃ7b9f260 (โœ“) + +"#]] + ); + + let id = id_by_rev(&repo, "main~1"); + let ws = Workspace::from_tip(id, None, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + // Detached states are also possible. They keep the anonymous container while + // preserving target context and pruning integrated commits. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +โŒ‚:DETACHED <> โœ“refs/remotes/origin/mainโ‡ฃ3 +โ””โ”€โ”€ โ‰ก:anon: {1} + โ””โ”€โ”€ :anon: + +"#]] + ); + Ok(()) +} + +#[test] +fn workspace_without_target_can_see_remote() -> anyhow::Result<()> { + let (mut repo, _) = read_only_in_memory_scenario("ws/main-with-remote-and-workspace-ref")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 956a3de (origin/main) on-remote-only +* 3183e43 (HEAD -> main, gitbutler/workspace) M1 + +"#]] + ); + + // Use an in-memory version directly as vb.toml can't bring in remote branches. + let mut meta = InMemoryRefMetadata::default(); + let ws_ref = "refs/heads/gitbutler/workspace".try_into()?; + let mut ws = meta.workspace(ws_ref)?; + for (idx, ref_name) in ["refs/heads/main", "refs/remotes/origin/main"] + .into_iter() + .enumerate() + { + ws.stacks.push(WorkspaceStack { + id: StackId::from_number_for_testing(idx as u128), + branches: vec![WorkspaceStackBranch { + ref_name: ref_name.try_into()?, + archived: false, + }], + workspacecommit_relation: WorkspaceCommitRelation::Merged, + }); + meta.branches.push(( + ref_name.try_into()?, + but_core::ref_metadata::Branch::default(), + )) + } + meta.set_workspace(&ws)?; + + let ws = + Workspace::from_head(&repo, &meta, project_meta(&meta), standard_options())?.validated()?; + // Main is a normal branch, and its remote is known. + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ยท956a3de (โŒ‚) โ–บorigin/main +* ๐Ÿ‘‰๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜) โ–บmain[๐ŸŒณ] <> origin/main +layout: + empty chain anchors: 3183e43^ +"#]] + ); + + // The workspace shows the remote commit, there is nothing special about the target. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:main[๐ŸŒณ] <> origin/mainโ‡ก1 {0} + โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:main[๐ŸŒณ] <> origin/mainโ‡ก1 + โ””โ”€โ”€ ยท3183e43 (๐Ÿ˜๏ธ) + +"#]] + ); + + // If the remote isn't setup officially, deduction still works as we find + // symbolic remote names for deduction in workspace ref names as well. + repo.config_snapshot_mut() + .remove_section("branch", Some("main".into())); + let ws = ws.redo(&repo, &meta, Overlay::default())?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ยท956a3de (โŒ‚) โ–บorigin/main +* ๐Ÿ‘‰๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜) โ–บmain[๐ŸŒณ] +layout: + empty chain anchors: 3183e43^ +"#]] + ); + + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:main[๐ŸŒณ] {0} + โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:main[๐ŸŒณ] + โ””โ”€โ”€ ยท3183e43 (๐Ÿ˜๏ธ) + +"#]] + ); + 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-without-remote")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 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 +| | * 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 + +"#]] + .raw() + ); + add_workspace_without_target(&mut meta); + assert!( + meta.data_mut().default_target.is_none(), + "without target, limits affect workspaces too" + ); + let ws = Workspace::from_head( + &repo, + &*meta, + project_meta(&*meta), + standard_options().with_limit_hint(0), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰โœ‚ยท4077353 (โŒ‚|๐Ÿ˜) +layout: + materialized parents: 4077353: +"#]] + ); + // The commit in the workspace branch is always ignored and is expected to be the workspace merge commit. + // So nothing to show here. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ: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. + // and there is no deductible remote relationship between origin/main and main (no remote not configured). + // Then the traversal ends on integrated branches as `main` isn't a target. + let ws = Workspace::from_head( + &repo, + &*meta, + project_meta(&*meta), + standard_options().with_limit_hint(0), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท4077353 (โŒ‚|๐Ÿ˜) +* ยท6b1a13b (โŒ‚|๐Ÿ˜) โ–บB +* ยท03ad472 (โŒ‚|๐Ÿ˜) +โ”‚ * ๐ŸŸฃd0df794 (โœ“) โ–บorigin/main +โ”‚ * ๐ŸŸฃ09c6e08 (โœ“) +โ”‚ * ๐ŸŸฃ7b9f260 (โœ“) +โ•ญโ”€โ”ค +โ”‚ * ๐ŸŸฃ4b3e5a8 (โœ“) โ–บmain <> origin/main +โ”‚ * ๐ŸŸฃ34d0715 (โœ“) +โ”‚ * ๐Ÿ๐ŸŸฃeb5f731 (โœ“) +* ยท79bbb29 (โŒ‚|๐Ÿ˜|โœ“) โ–บA +* ยทfc98174 (โŒ‚|๐Ÿ˜|โœ“) +* โœ‚ยทa381df5 (โŒ‚|๐Ÿ˜|โœ“) +layout: + materialized parents: 4077353: 6b1a13b +"#]] + ); + + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ6 on 79bbb29 +โ””โ”€โ”€ โ‰ก:B on 79bbb29 + โ””โ”€โ”€ :B + โ”œโ”€โ”€ ยท6b1a13b (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ยท03ad472 (๐Ÿ˜๏ธ) + +"#]] + ); + + Ok(()) +} + +#[test] +fn three_branches_one_advanced_ws_commit_advanced_fully_pushed_empty_dependent() +-> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario( + "ws/three-branches-one-advanced-ws-commit-advanced-fully-pushed-empty-dependent", + )?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* f8f33a7 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* cbc6713 (origin/advanced-lane, on-top-of-dependent, dependent, advanced-lane) change +* fafd9d0 (origin/main, main, lane) init + +"#]] + ); + + add_workspace(&mut meta); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทf8f33a7 (โŒ‚|๐Ÿ˜) +* ยทcbc6713 (โŒ‚|๐Ÿ˜) โ–บadvanced-lane, โ–บdependent, โ–บon-top-of-dependent, โ–บorigin/advanced-lane <> origin/advanced-lane +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บlane, โ–บmain, โ–บorigin/main <> origin/main +layout: + materialized parents: f8f33a7: cbc6713 +"#]] + ); + + // By default, the advanced lane is simply frozen as its remote contains the commit. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:advanced-lane <> origin/advanced-lane on fafd9d0 + โ””โ”€โ”€ :advanced-lane <> origin/advanced-lane + โ””โ”€โ”€ โ„๏ธcbc6713 (๐Ÿ˜๏ธ) โ–บdependent, โ–บon-top-of-dependent + +"#]] + ); + + add_stack_with_segments( + &mut meta, + 1, + "dependent", + StackState::InWorkspace, + &["advanced-lane"], + ); + + // Lanes are properly ordered + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทf8f33a7 (โŒ‚|๐Ÿ˜) +* ยทcbc6713 (โŒ‚|๐Ÿ˜) โ–บadvanced-lane, โ–บdependent, โ–บon-top-of-dependent, โ–บorigin/advanced-lane <> origin/advanced-lane +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บlane, โ–บmain, โ–บorigin/main <> origin/main +layout: + materialized parents: f8f33a7: cbc6713 + empty chain anchors: cbc6713^ +"#]] + ); + + // When putting the dependent branch on top as empty segment, the frozen state is retained. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:dependent on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:dependent + โ””โ”€โ”€ ๐Ÿ“™:advanced-lane <> origin/advanced-lane + โ””โ”€โ”€ โ„๏ธcbc6713 (๐Ÿ˜๏ธ) โ–บon-top-of-dependent + +"#]] + ); + 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")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[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); + // It sees the entire history as it had to find `main`. + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท2cde30a (โŒ‚|๐Ÿ˜|โœ“) โ–บA, โ–บB, โ–บC, โ–บD, โ–บE, โ–บF, โ–บorigin/main +* ยท1c938f4 (โŒ‚|๐Ÿ˜|โœ“) +* ยทb82769f (โŒ‚|๐Ÿ˜|โœ“) +* ยท988032f (โŒ‚|๐Ÿ˜|โœ“) +* ยทcd5b655 (โŒ‚|๐Ÿ˜|โœ“) +* ๐Ÿยท2be54cd (โŒ‚|๐Ÿ˜|โœ“) โ–บmain <> origin/main +"#]] + ); + // Workspace is empty as everything is integrated. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 2cde30a + +"#]] + ); + + add_stack_with_segments(&mut meta, 0, "C", StackState::InWorkspace, &["B", "A"]); + add_stack_with_segments(&mut meta, 1, "D", StackState::InWorkspace, &["E", "F"]); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท2cde30a (โŒ‚|๐Ÿ˜|โœ“) โ–บA, โ–บB, โ–บC, โ–บD, โ–บE, โ–บF, โ–บorigin/main +* ยท1c938f4 (โŒ‚|๐Ÿ˜|โœ“) +* ยทb82769f (โŒ‚|๐Ÿ˜|โœ“) +* ยท988032f (โŒ‚|๐Ÿ˜|โœ“) +* ยทcd5b655 (โŒ‚|๐Ÿ˜|โœ“) +* ๐Ÿยท2be54cd (โŒ‚|๐Ÿ˜|โœ“) โ–บmain <> origin/main +layout: + empty chain anchors: 2cde30a 2cde30a +"#]] + ); + + // Empty stack segments on top of integrated portions will show, and nothing integrated shows. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 2cde30a +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 2cde30a {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:D on 2cde30a {1} + โ”œโ”€โ”€ ๐Ÿ“™:D + โ”œโ”€โ”€ ๐Ÿ“™:E + โ””โ”€โ”€ ๐Ÿ“™:F + +"#]] + ); + + // However, when passing an additional old position of the target, we can show the now-integrated parts. + // The stacks will always be created on top of the integrated segments as that's where their references are + // (these segments are never conjured up out of thin air). + let ws = Workspace::from_head( + &repo, + &*meta, + project_meta(&*meta), + standard_options_with_extra_target(&repo, "main"), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 2be54cd +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 2be54cd {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ”‚ โ”œโ”€โ”€ ยท2cde30a (๐Ÿ˜๏ธ|โœ“) +โ”‚ โ”œโ”€โ”€ ยท1c938f4 (๐Ÿ˜๏ธ|โœ“) +โ”‚ โ”œโ”€โ”€ ยทb82769f (๐Ÿ˜๏ธ|โœ“) +โ”‚ โ”œโ”€โ”€ ยท988032f (๐Ÿ˜๏ธ|โœ“) +โ”‚ โ””โ”€โ”€ ยทcd5b655 (๐Ÿ˜๏ธ|โœ“) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:D on 2be54cd {1} + โ”œโ”€โ”€ ๐Ÿ“™:D + โ”œโ”€โ”€ ๐Ÿ“™:E + โ””โ”€โ”€ ๐Ÿ“™:F + โ”œโ”€โ”€ ยท2cde30a (๐Ÿ˜๏ธ|โœ“) + โ”œโ”€โ”€ ยท1c938f4 (๐Ÿ˜๏ธ|โœ“) + โ”œโ”€โ”€ ยทb82769f (๐Ÿ˜๏ธ|โœ“) + โ”œโ”€โ”€ ยท988032f (๐Ÿ˜๏ธ|โœ“) + โ””โ”€โ”€ ยทcd5b655 (๐Ÿ˜๏ธ|โœ“) + +"#]] + ); + 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")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[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 + +"#]] + .raw() + ); + + add_workspace(&mut meta); + let (main_id, main_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 ws = Workspace::from_tip( + main_id, + main_ref_name.clone(), + &*meta, + project_meta(&*meta), + standard_options(), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ยท41ed0e4 (โŒ‚|๐Ÿ˜) +โ”‚ * ๐ŸŸฃ232ed06 (โœ“) โ–บorigin/main +โ”‚ โ”œโ”€โ•ฎ +โ”‚ * โ”‚ ๐ŸŸฃabcfd9a (โœ“) โ–บworkspace-to-target +โ”‚ * โ”‚ ๐ŸŸฃbc86eba (โœ“) +โ”‚ * โ”‚ ๐ŸŸฃc7ae303 (โœ“) +โ”œโ”€โ•ฏ โ”‚ +โ”‚ * ๐ŸŸฃ9e2a79e (โœ“) โ–บlong-workspace-to-target +โ”‚ * ๐ŸŸฃfdeaa43 (โœ“) +โ”‚ * ๐ŸŸฃ30565ee (โœ“) +โ”‚ * ๐ŸŸฃ0c1c23a (โœ“) +โ”‚ * ๐ŸŸฃ56d152c (โœ“) +โ”‚ * ๐ŸŸฃe6e1360 (โœ“) +โ”‚ * ๐ŸŸฃ1a22a39 (โœ“) +โ”œโ”€โ”€โ”€โ•ฏ +* ยท9730cbf (โŒ‚|๐Ÿ˜|โœ“) โ–บworkspace +โ”œโ”€โ•ฎ +* โ”‚ ยทdc7ab57 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain-to-workspace +โ”‚ * ยท77f31a0 (โŒ‚|๐Ÿ˜|โœ“) โ–บlong-main-to-workspace +โ”‚ * ยทeb17e31 (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ * ยทfe2046b (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ * ยท5532ef5 (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ * ๐Ÿ‘‰ยท2438292 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain <> origin/main +โ”œโ”€โ•ฏ +* ยทc056b75 (โŒ‚|๐Ÿ˜|โœ“) +* ยทf49c977 (โŒ‚|๐Ÿ˜|โœ“) +* ยท7b7ebb2 (โŒ‚|๐Ÿ˜|โœ“) +* ยทdca4960 (โŒ‚|๐Ÿ˜|โœ“) +* ยท11c29b8 (โŒ‚|๐Ÿ˜|โœ“) +* ยทc32dd03 (โŒ‚|๐Ÿ˜|โœ“) +* ยทb625665 (โŒ‚|๐Ÿ˜|โœ“) +* ยทa821094 (โŒ‚|๐Ÿ˜|โœ“) +* ยทbce0c5e (โŒ‚|๐Ÿ˜|โœ“) +* ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“) +"#]] + ); + // Entrypoint is outside of the managed workspace, so it is projected as a + // single-branch view. Target context is preserved and integrated commits below + // the target trunk are pruned. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +โŒ‚:main <> โœ“refs/remotes/origin/mainโ‡ฃ11 +โ””โ”€โ”€ โ‰ก:main <> origin/mainโ‡ฃ11 {1} + โ””โ”€โ”€ :main <> origin/mainโ‡ฃ11 + โ”œโ”€โ”€ ๐ŸŸฃ232ed06 (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃabcfd9a (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃbc86eba (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃc7ae303 (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃ9e2a79e (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃfdeaa43 (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃ30565ee (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃ0c1c23a (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃ56d152c (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃe6e1360 (โœ“) + โ””โ”€โ”€ ๐ŸŸฃ1a22a39 (โœ“) + +"#]] + ); + + // 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 ws = Workspace::from_tip( + main_id, + main_ref_name, + &*meta, + project_meta(&*meta), + standard_options().with_limit_hint(1), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ยท41ed0e4 (โŒ‚|๐Ÿ˜) +โ”‚ * ๐ŸŸฃ232ed06 (โœ“) โ–บorigin/main +โ”‚ โ”œโ”€โ•ฎ +โ”‚ * โ”‚ ๐ŸŸฃabcfd9a (โœ“) โ–บworkspace-to-target +โ”‚ * โ”‚ ๐ŸŸฃbc86eba (โœ“) +โ”‚ * โ”‚ ๐ŸŸฃc7ae303 (โœ“) +โ”œโ”€โ•ฏ โ”‚ +โ”‚ * ๐ŸŸฃ9e2a79e (โœ“) โ–บlong-workspace-to-target +โ”‚ * ๐ŸŸฃfdeaa43 (โœ“) +โ”‚ * ๐ŸŸฃ30565ee (โœ“) +โ”‚ * ๐ŸŸฃ0c1c23a (โœ“) +โ”‚ * ๐ŸŸฃ56d152c (โœ“) +โ”‚ * ๐ŸŸฃe6e1360 (โœ“) +โ”‚ * ๐ŸŸฃ1a22a39 (โœ“) +โ”œโ”€โ”€โ”€โ•ฏ +* ยท9730cbf (โŒ‚|๐Ÿ˜|โœ“) โ–บworkspace +โ”œโ”€โ•ฎ +* โ”‚ ยทdc7ab57 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain-to-workspace +โ”‚ * ยท77f31a0 (โŒ‚|๐Ÿ˜|โœ“) โ–บlong-main-to-workspace +โ”‚ * ยทeb17e31 (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ * ยทfe2046b (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ * ยท5532ef5 (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ * ๐Ÿ‘‰ยท2438292 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain <> origin/main +โ”œโ”€โ•ฏ +* ยทc056b75 (โŒ‚|๐Ÿ˜|โœ“) +* ยทf49c977 (โŒ‚|๐Ÿ˜|โœ“) +* ยท7b7ebb2 (โŒ‚|๐Ÿ˜|โœ“) +* ยทdca4960 (โŒ‚|๐Ÿ˜|โœ“) +* โœ‚ยท11c29b8 (โŒ‚|๐Ÿ˜|โœ“) +"#]] + ); + // The limit is visible as well. Target context is preserved in the downgraded + // branch view, so integrated local/base commits are pruned. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +โŒ‚:main <> โœ“refs/remotes/origin/mainโ‡ฃ11 +โ””โ”€โ”€ โ‰ก:main <> origin/mainโ‡ฃ11 {1} + โ””โ”€โ”€ :main <> origin/mainโ‡ฃ11 + โ”œโ”€โ”€ ๐ŸŸฃ232ed06 (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃabcfd9a (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃbc86eba (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃc7ae303 (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃ9e2a79e (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃfdeaa43 (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃ30565ee (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃ0c1c23a (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃ56d152c (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃe6e1360 (โœ“) + โ””โ”€โ”€ ๐ŸŸฃ1a22a39 (โœ“) + +"#]] + ); + + // 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 ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท41ed0e4 (โŒ‚|๐Ÿ˜) +โ”‚ * ๐ŸŸฃ232ed06 (โœ“) โ–บorigin/main +โ”‚ โ”œโ”€โ•ฎ +โ”‚ * โ”‚ ๐ŸŸฃabcfd9a (โœ“) โ–บworkspace-to-target +โ”‚ * โ”‚ ๐ŸŸฃbc86eba (โœ“) +โ”‚ * โ”‚ ๐ŸŸฃc7ae303 (โœ“) +โ”œโ”€โ•ฏ โ”‚ +โ”‚ * ๐ŸŸฃ9e2a79e (โœ“) โ–บlong-workspace-to-target +โ”‚ * ๐ŸŸฃfdeaa43 (โœ“) +โ”‚ * ๐ŸŸฃ30565ee (โœ“) +โ”‚ * ๐ŸŸฃ0c1c23a (โœ“) +โ”‚ * ๐ŸŸฃ56d152c (โœ“) +โ”‚ * ๐ŸŸฃe6e1360 (โœ“) +โ”‚ * ๐ŸŸฃ1a22a39 (โœ“) +โ”œโ”€โ”€โ”€โ•ฏ +* ยท9730cbf (โŒ‚|๐Ÿ˜|โœ“) โ–บworkspace +โ”œโ”€โ•ฎ +* โ”‚ ยทdc7ab57 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain-to-workspace +โ”‚ * ยท77f31a0 (โŒ‚|๐Ÿ˜|โœ“) โ–บlong-main-to-workspace +โ”‚ * ยทeb17e31 (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ * ยทfe2046b (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ * ยท5532ef5 (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ * ยท2438292 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain <> origin/main +โ”œโ”€โ•ฏ +* ยทc056b75 (โŒ‚|๐Ÿ˜|โœ“) +* ยทf49c977 (โŒ‚|๐Ÿ˜|โœ“) +* ยท7b7ebb2 (โŒ‚|๐Ÿ˜|โœ“) +* ยทdca4960 (โŒ‚|๐Ÿ˜|โœ“) +* ยท11c29b8 (โŒ‚|๐Ÿ˜|โœ“) +* ยทc32dd03 (โŒ‚|๐Ÿ˜|โœ“) +* ยทb625665 (โŒ‚|๐Ÿ˜|โœ“) +* โœ‚ยทa821094 (โŒ‚|๐Ÿ˜|โœ“) +layout: + materialized parents: 41ed0e4: 9730cbf +"#]] + ); + + // Everything is integrated, nothing to see here. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ11 on 9730cbf + +"#]] + ); + Ok(()) +} + +#[test] +fn remote_far_in_ancestry() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/remote-far-in-ancestry")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 9412ebd (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 8407093 (A) A3 +* 7dfaa0c A2 +* 544e458 A1 +* 685d644 (origin/main, main) M12 +* cafdb27 M11 +* c056b75 M10 +* f49c977 M9 +* 7b7ebb2 M8 +* dca4960 M7 +* 11c29b8 M6 +* c32dd03 M5 +* b625665 M4 +* a821094 M3 +* bce0c5e M2 +| * 975754f (origin/A) R3 +| * f48ff69 R2 +|/ +* 3183e43 M1 + +"#]] + ); + + add_workspace(&mut meta); + let ws = Workspace::from_head( + &repo, + &*meta, + project_meta(&*meta), + standard_options().with_limit_hint(1), + )? + .validated()?; + // It's critical that the main branch isn't cut off and the local and remote part find each other, + // or else the remote part will go on forever create a lot of issues for those who want to display + // all these incorrectly labeled commits. + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท9412ebd (โŒ‚|๐Ÿ˜) +* ยท8407093 (โŒ‚|๐Ÿ˜) โ–บA <> origin/A +* ยท7dfaa0c (โŒ‚|๐Ÿ˜) +* ยท544e458 (โŒ‚|๐Ÿ˜) +* ยท685d644 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บorigin/main <> origin/main +* ยทcafdb27 (โŒ‚|๐Ÿ˜|โœ“) +* ยทc056b75 (โŒ‚|๐Ÿ˜|โœ“) +* ยทf49c977 (โŒ‚|๐Ÿ˜|โœ“) +* ยท7b7ebb2 (โŒ‚|๐Ÿ˜|โœ“) +* ยทdca4960 (โŒ‚|๐Ÿ˜|โœ“) +* ยท11c29b8 (โŒ‚|๐Ÿ˜|โœ“) +* ยทc32dd03 (โŒ‚|๐Ÿ˜|โœ“) +* ยทb625665 (โŒ‚|๐Ÿ˜|โœ“) +* ยทa821094 (โŒ‚|๐Ÿ˜|โœ“) +* ยทbce0c5e (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ * ๐ŸŸฃ975754f โ–บorigin/A +โ”‚ * ๐ŸŸฃf48ff69 +โ”œโ”€โ•ฏ +* ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“) +layout: + materialized parents: 9412ebd: 8407093 +"#]] + ); + + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 685d644 +โ””โ”€โ”€ โ‰ก:A <> origin/Aโ‡ก3โ‡ฃ2 on 685d644 + โ””โ”€โ”€ :A <> origin/Aโ‡ก3โ‡ฃ2 + โ”œโ”€โ”€ ๐ŸŸฃ975754f + โ”œโ”€โ”€ ๐ŸŸฃf48ff69 + โ”œโ”€โ”€ ยท8407093 (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ ยท7dfaa0c (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ยท544e458 (๐Ÿ˜๏ธ) + +"#]] + ); + 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")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[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 (B, A) M1 + +"#]] + .raw() + ); + + 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 ws = Workspace::from_tip( + id, + ref_name, + &*meta, + project_meta(&*meta), + standard_options(), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ยทf514495 (โŒ‚|๐Ÿ˜) +โ”‚ * ๐ŸŸฃ024f837 (โœ“) โ–บlong-workspace-to-target, โ–บorigin/main +โ”‚ * ๐ŸŸฃ64a8284 (โœ“) +โ”‚ * ๐ŸŸฃb72938c (โœ“) +โ”‚ * ๐ŸŸฃ9ccbf6f (โœ“) +โ”‚ * ๐ŸŸฃ5fa4905 (โœ“) +โ”‚ * ๐ŸŸฃ43074d3 (โœ“) +โ”‚ * ๐ŸŸฃ800d4a9 (โœ“) +โ”‚ * ๐ŸŸฃ742c068 (โœ“) +โ”‚ * ๐ŸŸฃfe06afd (โœ“) +โ”‚ * ๐ŸŸฃ3027746 (โœ“) +โ”‚ โ”œโ”€โ•ฎ +โ”‚ * โ”‚ ๐ŸŸฃf0d2a35 (โœ“) +โ”œโ”€โ•ฏ โ”‚ +* โ”‚ ยทc9120f1 (โŒ‚|๐Ÿ˜|โœ“) โ–บworkspace +โ”œโ”€โ•ฎ โ”‚ +โ”‚ * โ”‚ ยทb39c7ec (โŒ‚|๐Ÿ˜|โœ“) โ–บlong-main-to-workspace +โ”‚ * โ”‚ ยท2983a97 (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ * โ”‚ ยท144ea85 (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ * โ”‚ ยท5aecfd2 (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ * โ”‚ ๐Ÿ‘‰ยทbce0c5e (โŒ‚|๐Ÿ˜|โœ“) โ–บmain <> origin/main +โ”‚ โ”‚ * ๐ŸŸฃedf041f (โœ“) โ–บlonger-workspace-to-target +โ”‚ โ”‚ * ๐ŸŸฃd9f03f6 (โœ“) +โ”‚ โ”‚ * ๐ŸŸฃ8d1d264 (โœ“) +โ”‚ โ”‚ * ๐ŸŸฃfa7ceae (โœ“) +โ”‚ โ”‚ * ๐ŸŸฃ95bdbf1 (โœ“) +โ”‚ โ”‚ * ๐ŸŸฃ5bac978 (โœ“) +โ”œโ”€โ”€โ”€โ•ฏ +* โ”‚ ยท1126587 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain-to-workspace +โ”œโ”€โ•ฏ +* ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“) โ–บA, โ–บB +"#]] + ); + // `main` is integrated, but it is the entrypoint, so the branch container is shown. + // With preserved target context, integrated commits below the target trunk are pruned. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +โŒ‚:main <> โœ“refs/remotes/origin/mainโ‡ฃ17 +โ””โ”€โ”€ โ‰ก:main <> origin/mainโ‡ฃ17 {1} + โ””โ”€โ”€ :main <> origin/mainโ‡ฃ17 + โ”œโ”€โ”€ ๐ŸŸฃ024f837 (โœ“) โ–บlong-workspace-to-target + โ”œโ”€โ”€ ๐ŸŸฃ64a8284 (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃb72938c (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃ9ccbf6f (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃ5fa4905 (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃ43074d3 (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃ800d4a9 (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃ742c068 (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃfe06afd (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃ3027746 (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃf0d2a35 (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃedf041f (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃd9f03f6 (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃ8d1d264 (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃfa7ceae (โœ“) + โ”œโ”€โ”€ ๐ŸŸฃ95bdbf1 (โœ“) + โ””โ”€โ”€ ๐ŸŸฃ5bac978 (โœ“) + +"#]] + ); + + // 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 ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทf514495 (โŒ‚|๐Ÿ˜) +โ”‚ * ๐ŸŸฃ024f837 (โœ“) โ–บlong-workspace-to-target, โ–บorigin/main +โ”‚ * ๐ŸŸฃ64a8284 (โœ“) +โ”‚ * ๐ŸŸฃb72938c (โœ“) +โ”‚ * ๐ŸŸฃ9ccbf6f (โœ“) +โ”‚ * ๐ŸŸฃ5fa4905 (โœ“) +โ”‚ * ๐ŸŸฃ43074d3 (โœ“) +โ”‚ * ๐ŸŸฃ800d4a9 (โœ“) +โ”‚ * ๐ŸŸฃ742c068 (โœ“) +โ”‚ * ๐ŸŸฃfe06afd (โœ“) +โ”‚ * ๐ŸŸฃ3027746 (โœ“) +โ”‚ โ”œโ”€โ•ฎ +โ”‚ * โ”‚ ๐ŸŸฃf0d2a35 (โœ“) +โ”œโ”€โ•ฏ โ”‚ +* โ”‚ ยทc9120f1 (โŒ‚|๐Ÿ˜|โœ“) โ–บworkspace +โ”œโ”€โ•ฎ โ”‚ +โ”‚ * โ”‚ ยทb39c7ec (โŒ‚|๐Ÿ˜|โœ“) โ–บlong-main-to-workspace +โ”‚ * โ”‚ ยท2983a97 (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ * โ”‚ ยท144ea85 (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ * โ”‚ ยท5aecfd2 (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ * โ”‚ ยทbce0c5e (โŒ‚|๐Ÿ˜|โœ“) โ–บmain <> origin/main +โ”‚ โ”‚ * ๐ŸŸฃedf041f (โœ“) โ–บlonger-workspace-to-target +โ”‚ โ”‚ * ๐ŸŸฃd9f03f6 (โœ“) +โ”‚ โ”‚ * ๐ŸŸฃ8d1d264 (โœ“) +โ”‚ โ”‚ * ๐ŸŸฃfa7ceae (โœ“) +โ”‚ โ”‚ * ๐ŸŸฃ95bdbf1 (โœ“) +โ”‚ โ”‚ * ๐ŸŸฃ5bac978 (โœ“) +โ”œโ”€โ”€โ”€โ•ฏ +* โ”‚ ยท1126587 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain-to-workspace +โ”œโ”€โ•ฏ +* ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“) โ–บA, โ–บB +layout: + materialized parents: f514495: c9120f1 +"#]] + ); + + // Everything is integrated. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ17 on c9120f1 + +"#]] + ); + + // With a lower base for the target, we see more. + let target_commit_id = repo.rev_parse_single("3183e43")?.detach(); + add_workspace_with_target(&mut meta, target_commit_id); + + let ws = ws.redo(&repo, &*meta, Overlay::default())?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ17 on c9120f1 + +"#]] + ); + + // We can also add independent virtual branches to that new base. + add_stack(&mut meta, 3, "A", StackState::InWorkspace); + add_stack(&mut meta, 4, "B", StackState::InWorkspace); + let ws = ws.redo(&repo, &*meta, Overlay::default())?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ17 on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {3} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {4} + โ””โ”€โ”€ ๐Ÿ“™:B + +"#]] + ); + + // We can also add stacked virtual branches to that new base. + meta.data_mut().branches.clear(); + add_workspace_with_target(&mut meta, target_commit_id); + add_stack_with_segments(&mut meta, 3, "A", StackState::InWorkspace, &["B"]); + let ws = ws.redo(&repo, &*meta, Overlay::default())?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ17 on 3183e43 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {3} + โ””โ”€โ”€ ๐Ÿ“™:A + +"#]] + ); + Ok(()) +} + +#[test] +fn multi_lane_with_shared_segment_one_integrated() -> anyhow::Result<()> { + let (repo, mut meta) = + read_only_in_memory_scenario("ws/multi-lane-with-shared-segment-one-integrated")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +*-. 1cf594d (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|\ \ +| | * 9895054 (D) D1 +| | * de625cc (C) C3 +| | * 23419f8 C2 +| | * 5dc4389 C1 +| * | acdc49a (B) B2 +| * | f0117e0 B1 +| |/ +| | * c08dc6b (origin/main) Merge branch 'A' into soon-remote-main +| | |\ +| |_|/ +|/| | +* | | 0bad3af (A) A1 +|/ / +* | d4f537e (shared) S3 +* | b448757 S2 +* | e9a378d S1 +|/ +* 3183e43 (main) M1 + +"#]] + .raw() + ); + + add_workspace(&mut meta); + + let ws = Workspace::from_head( + &repo, + &*meta, + project_meta(&*meta), + standard_options_with_extra_target(&repo, "main"), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท1cf594d (โŒ‚|๐Ÿ˜) +โ”œโ”€โ”ฌโ”€โ•ฎ +โ”‚ * โ”‚ ยทacdc49a (โŒ‚|๐Ÿ˜) โ–บB +โ”‚ * โ”‚ ยทf0117e0 (โŒ‚|๐Ÿ˜) +โ”‚ โ”‚ * ยท9895054 (โŒ‚|๐Ÿ˜) โ–บD +โ”‚ โ”‚ * ยทde625cc (โŒ‚|๐Ÿ˜) โ–บC +โ”‚ โ”‚ * ยท23419f8 (โŒ‚|๐Ÿ˜) +โ”‚ โ”‚ * ยท5dc4389 (โŒ‚|๐Ÿ˜) +โ”‚ โ”œโ”€โ•ฏ +โ”‚ โ”‚ * ๐ŸŸฃc08dc6b (โœ“) โ–บorigin/main +โ•ญโ”€โ”€โ”€โ”ค +* โ”‚ โ”‚ ยท0bad3af (โŒ‚|๐Ÿ˜|โœ“) โ–บA +โ”œโ”€โ•ฏ โ”‚ +* โ”‚ ยทd4f537e (โŒ‚|๐Ÿ˜|โœ“) โ–บshared +* โ”‚ ยทb448757 (โŒ‚|๐Ÿ˜|โœ“) +* โ”‚ ยทe9a378d (โŒ‚|๐Ÿ˜|โœ“) +โ”œโ”€โ”€โ”€โ•ฏ +* ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain <> origin/main +layout: + materialized parents: 1cf594d: 0bad3af acdc49a 9895054 +"#]] + ); + + // A is still shown despite it being fully integrated, as it's still enclosed by the + // workspace tip and the fork-point, at least when we provide the previous known location of the target. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 +โ”œโ”€โ”€ โ‰ก:A on 3183e43 +โ”‚ โ”œโ”€โ”€ :A +โ”‚ โ”‚ โ””โ”€โ”€ ยท0bad3af (๐Ÿ˜๏ธ|โœ“) +โ”‚ โ””โ”€โ”€ :shared +โ”‚ โ”œโ”€โ”€ ยทd4f537e (๐Ÿ˜๏ธ|โœ“) +โ”‚ โ”œโ”€โ”€ ยทb448757 (๐Ÿ˜๏ธ|โœ“) +โ”‚ โ””โ”€โ”€ ยทe9a378d (๐Ÿ˜๏ธ|โœ“) +โ”œโ”€โ”€ โ‰ก:B on 3183e43 +โ”‚ โ”œโ”€โ”€ :B +โ”‚ โ”‚ โ”œโ”€โ”€ ยทacdc49a (๐Ÿ˜๏ธ) +โ”‚ โ”‚ โ””โ”€โ”€ ยทf0117e0 (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ :shared +โ”‚ โ”œโ”€โ”€ ยทd4f537e (๐Ÿ˜๏ธ|โœ“) +โ”‚ โ”œโ”€โ”€ ยทb448757 (๐Ÿ˜๏ธ|โœ“) +โ”‚ โ””โ”€โ”€ ยทe9a378d (๐Ÿ˜๏ธ|โœ“) +โ””โ”€โ”€ โ‰ก:D on 3183e43 + โ”œโ”€โ”€ :D + โ”‚ โ””โ”€โ”€ ยท9895054 (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ :C + โ”‚ โ”œโ”€โ”€ ยทde625cc (๐Ÿ˜๏ธ) + โ”‚ โ”œโ”€โ”€ ยท23419f8 (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ ยท5dc4389 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :shared + โ”œโ”€โ”€ ยทd4f537e (๐Ÿ˜๏ธ|โœ“) + โ”œโ”€โ”€ ยทb448757 (๐Ÿ˜๏ธ|โœ“) + โ””โ”€โ”€ ยทe9a378d (๐Ÿ˜๏ธ|โœ“) + +"#]] + ); + + // If we do not, integrated portions are removed. + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on d4f537e +โ”œโ”€โ”€ โ‰ก:B on d4f537e +โ”‚ โ””โ”€โ”€ :B +โ”‚ โ”œโ”€โ”€ ยทacdc49a (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ยทf0117e0 (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก:D on d4f537e + โ”œโ”€โ”€ :D + โ”‚ โ””โ”€โ”€ ยท9895054 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :C + โ”œโ”€โ”€ ยทde625cc (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ ยท23419f8 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ยท5dc4389 (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +#[test] +fn multi_lane_with_shared_segment() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/multi-lane-with-shared-segment")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +*-. 1cf594d (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|\ \ +| | * 9895054 (D) D1 +| | * de625cc (C) C3 +| | * 23419f8 C2 +| | * 5dc4389 C1 +| * | acdc49a (B) B2 +| * | f0117e0 B1 +| |/ +* / 0bad3af (A) A1 +|/ +* d4f537e (shared) S3 +* b448757 S2 +* e9a378d S1 +| * bce0c5e (origin/main) M2 +|/ +* 3183e43 (main) M1 + +"#]] + .raw() + ); + + add_workspace(&mut meta); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท1cf594d (โŒ‚|๐Ÿ˜) +โ”œโ”€โ”ฌโ”€โ•ฎ +* โ”‚ โ”‚ ยท0bad3af (โŒ‚|๐Ÿ˜) โ–บA +โ”‚ * โ”‚ ยทacdc49a (โŒ‚|๐Ÿ˜) โ–บB +โ”‚ * โ”‚ ยทf0117e0 (โŒ‚|๐Ÿ˜) +โ”œโ”€โ•ฏ โ”‚ +โ”‚ * ยท9895054 (โŒ‚|๐Ÿ˜) โ–บD +โ”‚ * ยทde625cc (โŒ‚|๐Ÿ˜) โ–บC +โ”‚ * ยท23419f8 (โŒ‚|๐Ÿ˜) +โ”‚ * ยท5dc4389 (โŒ‚|๐Ÿ˜) +โ”œโ”€โ”€โ”€โ•ฏ +* ยทd4f537e (โŒ‚|๐Ÿ˜) โ–บshared +* ยทb448757 (โŒ‚|๐Ÿ˜) +* ยทe9a378d (โŒ‚|๐Ÿ˜) +โ”‚ * ๐ŸŸฃbce0c5e (โœ“) โ–บorigin/main +โ”œโ”€โ•ฏ +* ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain <> origin/main +layout: + materialized parents: 1cf594d: 0bad3af acdc49a 9895054 +"#]] + ); + + // Segments can definitely repeat + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 +โ”œโ”€โ”€ โ‰ก:A on 3183e43 +โ”‚ โ”œโ”€โ”€ :A +โ”‚ โ”‚ โ””โ”€โ”€ ยท0bad3af (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ :shared +โ”‚ โ”œโ”€โ”€ ยทd4f537e (๐Ÿ˜๏ธ) +โ”‚ โ”œโ”€โ”€ ยทb448757 (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ยทe9a378d (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก:B on 3183e43 +โ”‚ โ”œโ”€โ”€ :B +โ”‚ โ”‚ โ”œโ”€โ”€ ยทacdc49a (๐Ÿ˜๏ธ) +โ”‚ โ”‚ โ””โ”€โ”€ ยทf0117e0 (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ :shared +โ”‚ โ”œโ”€โ”€ ยทd4f537e (๐Ÿ˜๏ธ) +โ”‚ โ”œโ”€โ”€ ยทb448757 (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ยทe9a378d (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก:D on 3183e43 + โ”œโ”€โ”€ :D + โ”‚ โ””โ”€โ”€ ยท9895054 (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ :C + โ”‚ โ”œโ”€โ”€ ยทde625cc (๐Ÿ˜๏ธ) + โ”‚ โ”œโ”€โ”€ ยท23419f8 (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ ยท5dc4389 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :shared + โ”œโ”€โ”€ ยทd4f537e (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ ยทb448757 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ยทe9a378d (๐Ÿ˜๏ธ) + +"#]] + ); + + let (id, ref_name) = id_at(&repo, "A"); + let ws = Workspace::from_tip( + id, + Some(ref_name), + &*meta, + project_meta(&*meta), + standard_options(), + )? + .validated()?; + // Checking out anything inside the workspace yields the same result. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ‘‰:A on 3183e43 +โ”‚ โ”œโ”€โ”€ ๐Ÿ‘‰:A +โ”‚ โ”‚ โ””โ”€โ”€ ยท0bad3af (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ :shared +โ”‚ โ”œโ”€โ”€ ยทd4f537e (๐Ÿ˜๏ธ) +โ”‚ โ”œโ”€โ”€ ยทb448757 (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ยทe9a378d (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก:B on 3183e43 +โ”‚ โ”œโ”€โ”€ :B +โ”‚ โ”‚ โ”œโ”€โ”€ ยทacdc49a (๐Ÿ˜๏ธ) +โ”‚ โ”‚ โ””โ”€โ”€ ยทf0117e0 (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ :shared +โ”‚ โ”œโ”€โ”€ ยทd4f537e (๐Ÿ˜๏ธ) +โ”‚ โ”œโ”€โ”€ ยทb448757 (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ยทe9a378d (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก:D on 3183e43 + โ”œโ”€โ”€ :D + โ”‚ โ””โ”€โ”€ ยท9895054 (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ :C + โ”‚ โ”œโ”€โ”€ ยทde625cc (๐Ÿ˜๏ธ) + โ”‚ โ”œโ”€โ”€ ยท23419f8 (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ ยท5dc4389 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :shared + โ”œโ”€โ”€ ยทd4f537e (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ ยทb448757 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ยทe9a378d (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +#[test] +fn local_branch_tracking_the_target_does_not_duplicate_the_target_segment() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/multi-lane-with-shared-segment")?; + add_workspace(&mut meta); + + // `main` tracks the target `origin/main`. Remote-tracking discovery at `main` must + // recognize the project-metadata target ref as already queued instead of inserting + // a second `origin/main` segment, which can leave disconnected segments behind. + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + let target_positions = ws.commit_graph().layout().map_or(0, |l| { + l.placements() + .filter(|(name, _)| name.as_bstr() == "refs/remotes/origin/main") + .count() + }); + assert_eq!( + target_positions, 1, + "the initial target tip owns the only position for the target ref" + ); + Ok(()) +} + +#[test] +fn dependent_branch_insertion() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario( + "ws/two-branches-one-advanced-two-parent-ws-commit-advanced-fully-pushed-empty-dependent", + )?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 335d6f2 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|\ +| * cbc6713 (origin/advanced-lane, dependent, advanced-lane) change +|/ +* fafd9d0 (origin/main, main, lane) init + +"#]] + .raw() + ); + + add_stack_with_segments( + &mut meta, + 1, + "dependent", + StackState::InWorkspace, + &["advanced-lane"], + ); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท335d6f2 (โŒ‚|๐Ÿ˜) +โ”œโ”€โ•ฎ +โ”‚ * ยทcbc6713 (โŒ‚|๐Ÿ˜) โ–บadvanced-lane, โ–บdependent, โ–บorigin/advanced-lane <> origin/advanced-lane +โ”œโ”€โ•ฏ +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บlane, โ–บmain, โ–บorigin/main <> origin/main +layout: + materialized parents: 335d6f2: fafd9d0 cbc6713 + empty chain anchors: cbc6713^ +"#]] + ); + + // The dependent branch is empty and on top of the one with the remote + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:dependent on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:dependent + โ””โ”€โ”€ ๐Ÿ“™:advanced-lane <> origin/advanced-lane + โ””โ”€โ”€ โ„๏ธcbc6713 (๐Ÿ˜๏ธ) + +"#]] + ); + + // Create the dependent branch below. + add_stack_with_segments( + &mut meta, + 1, + "advanced-lane", + StackState::InWorkspace, + &["dependent"], + ); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท335d6f2 (โŒ‚|๐Ÿ˜) +โ”œโ”€โ•ฎ +โ”‚ * ยทcbc6713 (โŒ‚|๐Ÿ˜) โ–บadvanced-lane, โ–บdependent, โ–บorigin/advanced-lane <> origin/advanced-lane +โ”œโ”€โ•ฏ +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บlane, โ–บmain, โ–บorigin/main <> origin/main +layout: + materialized parents: 335d6f2: fafd9d0 cbc6713 + empty chain anchors: cbc6713^ +"#]] + ); + + // Having done something unusual, which is to put the dependent branch + // underneath the other already pushed, it creates a different view of ownership. + // It's probably OK to leave it like this for now, and instead allow users to reorder + // these more easily. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:advanced-lane <> origin/advanced-lane on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:advanced-lane <> origin/advanced-lane + โ””โ”€โ”€ ๐Ÿ“™:dependent + โ””โ”€โ”€ โ„cbc6713 (๐Ÿ˜๏ธ) + +"#]] + ); + + let (id, ref_name) = id_at(&repo, "advanced-lane"); + let ws = Workspace::from_tip( + id, + ref_name, + &*meta, + project_meta(&*meta), + standard_options(), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:advanced-lane <> origin/advanced-lane on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:advanced-lane <> origin/advanced-lane + โ””โ”€โ”€ ๐Ÿ“™:dependent + โ””โ”€โ”€ โ„cbc6713 (๐Ÿ˜๏ธ) + +"#]] + ); + + let (id, ref_name) = id_at(&repo, "dependent"); + let ws = Workspace::from_tip( + id, + ref_name, + &*meta, + project_meta(&*meta), + standard_options(), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:advanced-lane <> origin/advanced-lane on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:advanced-lane <> origin/advanced-lane + โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:dependent + โ””โ”€โ”€ โ„cbc6713 (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +#[test] +fn multiple_stacks_with_shared_parent_and_remote() -> anyhow::Result<()> { + let (repo, mut meta) = + read_only_in_memory_scenario("ws/multiple-stacks-with-shared-segment-and-remote")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* baed751 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|\ +| * 4f1bb32 (C-on-A) C-on-A +* | aff8449 (B-on-A) B-on-A +|/ +| * b627ca7 (origin/A) A-on-remote +|/ +* e255adc (A) A +* fafd9d0 (origin/main, main) init + +"#]] + .raw() + ); + + add_stack_with_segments(&mut meta, 1, "C-on-A", StackState::InWorkspace, &[]); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทbaed751 (โŒ‚|๐Ÿ˜) +โ”œโ”€โ•ฎ +* โ”‚ ยทaff8449 (โŒ‚|๐Ÿ˜) โ–บB-on-A +โ”‚ * ยท4f1bb32 (โŒ‚|๐Ÿ˜) โ–บC-on-A +โ”œโ”€โ•ฏ +โ”‚ * ๐ŸŸฃb627ca7 โ–บorigin/A +โ”œโ”€โ•ฏ +* ยทe255adc (โŒ‚|๐Ÿ˜) โ–บA <> origin/A +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บorigin/main <> origin/main +layout: + materialized parents: baed751: aff8449 4f1bb32 + empty chain anchors: 4f1bb32^ +"#]] + ); + + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก:B-on-A on fafd9d0 +โ”‚ โ”œโ”€โ”€ :B-on-A +โ”‚ โ”‚ โ””โ”€โ”€ ยทaff8449 (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ :A <> origin/Aโ‡ฃ1 +โ”‚ โ”œโ”€โ”€ ๐ŸŸฃb627ca7 +โ”‚ โ””โ”€โ”€ โ„๏ธe255adc (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C-on-A on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:C-on-A + โ”‚ โ””โ”€โ”€ ยท4f1bb32 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :A <> origin/Aโ‡ฃ1 + โ”œโ”€โ”€ ๐ŸŸฃb627ca7 + โ””โ”€โ”€ โ„๏ธe255adc (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +#[test] +fn a_stack_segment_can_be_a_segment_elsewhere_and_stack_order() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario( + "ws/two-branches-one-advanced-two-parent-ws-commit-diverged-ttb", + )?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 873d056 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|\ +* | cbc6713 (advanced-lane) change +|/ +* fafd9d0 (main, lane) init +* da83717 (origin/main) disjoint remote target + +"#]] + .raw() + ); + + let lanes = ["advanced-lane", "lane"]; + for (idx, name) in lanes.into_iter().enumerate() { + add_stack_with_segments(&mut meta, idx, name, StackState::InWorkspace, &[]); + } + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท873d056 (โŒ‚|๐Ÿ˜) +โ”œโ”€โ•ฎ +* โ”‚ ยทcbc6713 (โŒ‚|๐Ÿ˜) โ–บadvanced-lane +โ”œโ”€โ•ฏ +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) โ–บlane, โ–บmain <> origin/main +* ๐Ÿ๐ŸŸฃda83717 (โœ“) โ–บorigin/main +layout: + materialized parents: 873d056: cbc6713 fafd9d0 + empty chain anchors: cbc6713^ fafd9d0^ +"#]] + ); + + // Since `lane` is connected directly, no segment has to be created. + // However, as nothing is integrated, it really is another name for `main` now, + // `main` is nothing special. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:advanced-lane on fafd9d0 {0} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:advanced-lane +โ”‚ โ””โ”€โ”€ ยทcbc6713 (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:lane on fafd9d0 {1} + โ””โ”€โ”€ ๐Ÿ“™:lane + +"#]] + ); + + // Reverse the order of stacks in the worktree data. + for (idx, name) in lanes.into_iter().rev().enumerate() { + add_stack_with_segments(&mut meta, idx, name, StackState::InWorkspace, &[]); + } + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท873d056 (โŒ‚|๐Ÿ˜) +โ”œโ”€โ•ฎ +* โ”‚ ยทcbc6713 (โŒ‚|๐Ÿ˜) โ–บadvanced-lane +โ”œโ”€โ•ฏ +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) โ–บlane, โ–บmain <> origin/main +* ๐Ÿ๐ŸŸฃda83717 (โœ“) โ–บorigin/main +layout: + materialized parents: 873d056: cbc6713 fafd9d0 + empty chain anchors: fafd9d0^ cbc6713^ +"#]] + ); + + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:advanced-lane on fafd9d0 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:advanced-lane +โ”‚ โ””โ”€โ”€ ยทcbc6713 (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:lane on fafd9d0 {0} + โ””โ”€โ”€ ๐Ÿ“™:lane + +"#]] + ); + Ok(()) +} + +#[test] +fn two_dependent_branches_with_embedded_remote() -> anyhow::Result<()> { + let (repo, mut meta) = + read_only_in_memory_scenario("ws/two-dependent-branches-with-interesting-remote-setup")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* a221221 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* aadad9d (A) shared by name +* 96a2408 (origin/main) another unrelated +| * 2b1808c (origin/A) shared by name +|/ +* f15ca75 (integrated) other integrated +* 9456d79 integrated in target +* fafd9d0 (main) init + +"#]] + ); + + // Just a single explicit reference we want to know of. + add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &[]); + + // Note how the target remote tracking branch is integrated into the stack + let ws = Workspace::from_head( + &repo, + &*meta, + project_meta(&*meta), + standard_options_with_extra_target(&repo, "main"), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทa221221 (โŒ‚|๐Ÿ˜) +* ยทaadad9d (โŒ‚|๐Ÿ˜) โ–บA <> origin/A +* ยท96a2408 (โŒ‚|๐Ÿ˜|โœ“) โ–บorigin/main +โ”‚ * ๐ŸŸฃ2b1808c โ–บorigin/A +โ”œโ”€โ•ฏ +* ยทf15ca75 (โŒ‚|๐Ÿ˜|โœ“) โ–บintegrated +* ยท9456d79 (โŒ‚|๐Ÿ˜|โœ“) +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain <> origin/main +layout: + materialized parents: a221221: aadad9d + empty chain anchors: aadad9d^ +"#]] + ); + + // Remote tracking branches we just want to aggregate, just like anonymous segments, + // but only when another target is provided (the old position, `main`). + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A <> origin/Aโ‡ก1โ‡ฃ1 on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:A <> origin/Aโ‡ก1โ‡ฃ1 + โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ2b1808c + โ”‚ โ”œโ”€โ”€ ยทaadad9d (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ ยท96a2408 (๐Ÿ˜๏ธ|โœ“) + โ””โ”€โ”€ :integrated + โ”œโ”€โ”€ โ„f15ca75 (๐Ÿ˜๏ธ|โœ“) + โ””โ”€โ”€ โ„9456d79 (๐Ÿ˜๏ธ|โœ“) + +"#]] + ); + + // Otherwise, nothing that's integrated is shown. Note how 96a2408 seems missing, + // but it's skipped because it's actually part of an integrated otherwise ignored segment. + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 96a2408 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A <> origin/Aโ‡ก1โ‡ฃ1 on 96a2408 {1} + โ””โ”€โ”€ ๐Ÿ“™:A <> origin/Aโ‡ก1โ‡ฃ1 + โ”œโ”€โ”€ ๐ŸŸฃ2b1808c + โ””โ”€โ”€ ยทaadad9d (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +#[test] +fn two_dependent_branches_rebased_with_remotes_merge_local() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario( + "ws/two-dependent-branches-rebased-with-remotes-merge-one-local", + )?; + // Each of the stacked branches has a remote, and the local branch was merged into main. + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* e0bd0a7 (origin/B) B +* 0b6b861 (origin/A) A +| * b694668 (origin/main) Merge branch 'A' into soon-origin-main +|/| +| | * 4f08b8d (HEAD -> gitbutler/workspace) GitButler Workspace Commit +| | * da597e8 (B) B +| |/ +| * 1818c17 (A) A +|/ +* 281456a (main) init + +"#]] + ); + + add_stack_with_segments(&mut meta, 0, "B", StackState::InWorkspace, &["A"]); + + let ws = Workspace::from_head( + &repo, + &*meta, + project_meta(&*meta), + standard_options_with_extra_target(&repo, "main"), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท4f08b8d (โŒ‚|๐Ÿ˜) +* ยทda597e8 (โŒ‚|๐Ÿ˜) โ–บB <> origin/B +โ”‚ * ๐ŸŸฃb694668 (โœ“) โ–บorigin/main +โ•ญโ”€โ”ค +* โ”‚ ยท1818c17 (โŒ‚|๐Ÿ˜|โœ“) โ–บA <> origin/A +โ”œโ”€โ•ฏ +โ”‚ * ๐ŸŸฃe0bd0a7 โ–บorigin/B +โ”‚ * ๐ŸŸฃ0b6b861 โ–บorigin/A +โ”œโ”€โ•ฏ +* ๐Ÿยท281456a (โŒ‚|๐Ÿ˜|โœ“) โ–บmain <> origin/main +layout: + materialized parents: 4f08b8d: da597e8 + empty chain anchors: da597e8^ +"#]] + ); + + // This is the default as it includes both the integrated and non-integrated segment. + // Note how there is no expensive computation to see if remote commits are the same, + // it's all ID-based. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 281456a +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B <> origin/Bโ‡ก1โ‡ฃ1 on 281456a {0} + โ”œโ”€โ”€ ๐Ÿ“™:B <> origin/Bโ‡ก1โ‡ฃ1 + โ”‚ โ”œโ”€โ”€ ๐ŸŸฃe0bd0a7 + โ”‚ โ””โ”€โ”€ ยทda597e8 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ๐Ÿ“™:A <> origin/Aโ‡ฃ1 + โ”œโ”€โ”€ ๐ŸŸฃ0b6b861 + โ””โ”€โ”€ ยท1818c17 (๐Ÿ˜๏ธ|โœ“) + +"#]] + ); + + let ws = Workspace::from_head( + &repo, + &*meta, + project_meta(&*meta), + standard_options_with_extra_target(&repo, "A"), + )? + .validated()?; + // Pretending we are rebased onto A still shows the same remote commits. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 1818c17 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B <> origin/Bโ‡ก1โ‡ฃ1 on 1818c17 {0} + โ””โ”€โ”€ ๐Ÿ“™:B <> origin/Bโ‡ก1โ‡ฃ1 + โ”œโ”€โ”€ ๐ŸŸฃe0bd0a7 + โ””โ”€โ”€ ยทda597e8 (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +#[test] +fn stacked_bottom_remote_still_points_at_now_split_top() -> anyhow::Result<()> { + let (repo, mut meta) = + read_only_in_memory_scenario("ws/stacked-bottom-remote-still-points-at-now-split-top")?; + // origin/bottom still points at T (the previously combined push), but the + // local stack is now split so that bottom holds only B and top holds T on + // top of bottom. To remove T from origin/bottom we'd need to force-push, + // so bottom must report `commits_on_remote` containing T. + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 5c66c47 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* bfbff44 (origin/bottom, top) T +* 7fdb58d (bottom) B +* fafd9d0 (origin/main, main) init + +"#]] + ); + + add_stack_with_segments(&mut meta, 0, "top", StackState::InWorkspace, &["bottom"]); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:top on fafd9d0 {0} + โ”œโ”€โ”€ ๐Ÿ“™:top + โ”‚ โ””โ”€โ”€ โ„bfbff44 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ๐Ÿ“™:bottom <> origin/bottomโ‡ฃ1 + โ”œโ”€โ”€ ๐ŸŸฃbfbff44 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ โ„๏ธ7fdb58d (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +#[test] +fn two_dependent_branches_rebased_with_remotes_squash_merge_remote_ambiguous() -> anyhow::Result<()> +{ + let (repo, mut meta) = read_only_in_memory_scenario( + "ws/two-dependent-branches-rebased-with-remotes-squash-merge-one-remote-ambiguous", + )?; + // Each of the stacked branches has a remote, the remote branch was merged into main, + // and the remaining branch B was rebased onto the merge, simulating a workspace update. + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 1109eb2 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 624e118 (D) D +* 0b6b861 (origin/main, main) A +| * 3045ea6 (origin/D) D +| * 1818c17 (origin/C, origin/B, origin/A) A +|/ +* 281456a init + +"#]] + ); + + // The branch A, B, C are not in the workspace anymore, and we *could* signal it by removing metadata. + // But even with metadata, it still works fine. + add_stack_with_segments(&mut meta, 0, "D", StackState::InWorkspace, &["C", "B", "A"]); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท1109eb2 (โŒ‚|๐Ÿ˜) +* ยท624e118 (โŒ‚|๐Ÿ˜) โ–บD <> origin/D +* ยท0b6b861 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บorigin/main <> origin/main +โ”‚ * ๐ŸŸฃ3045ea6 โ–บorigin/D +โ”‚ * ๐ŸŸฃ1818c17 โ–บorigin/A, โ–บorigin/B, โ–บorigin/C +โ”œโ”€โ•ฏ +* ๐Ÿยท281456a (โŒ‚|๐Ÿ˜|โœ“) +layout: + materialized parents: 1109eb2: 624e118 + empty chain anchors: 624e118^ +"#]] + ); + + let ambiguous_remote_tip = repo.rev_parse_single("origin/A")?.detach(); + for remote_ref in [ + "refs/remotes/origin/A", + "refs/remotes/origin/B", + "refs/remotes/origin/C", + ] { + let remote_ref = super::ref_name(remote_ref); + assert_eq!( + ws.commit_graph().commit_by_ref(remote_ref.as_ref()), + Some(ambiguous_remote_tip), + "{remote_ref} should resolve to the commit its Git ref points to, showing that something special happened here" + ); + } + + // only one remote commit as unrelated remotes split a linear segment + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 0b6b861 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:D <> origin/Dโ‡ก1โ‡ฃ1 on 0b6b861 {0} + โ””โ”€โ”€ ๐Ÿ“™:D <> origin/Dโ‡ก1โ‡ฃ1 + โ”œโ”€โ”€ ๐ŸŸฃ3045ea6 + โ””โ”€โ”€ ยท624e118 (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +#[test] +fn two_dependent_branches_rebased_with_remotes_squash_merge_remote() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario( + "ws/two-dependent-branches-rebased-with-remotes-squash-merge-one-remote", + )?; + // Each of the stacked branches has a remote, the remote branch was merged into main, + // and the remaining branch B was rebased onto the merge, simulating a workspace update. + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* deeae50 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 353471f (D) D +* 8a4b945 C +* e0bd0a7 B +* 0b6b861 (origin/main, main) A +| * bbd4ff6 (origin/D) D +| * e5f5a87 (origin/C) C +| * da597e8 (origin/B) B +| * 1818c17 (origin/A) A +|/ +* 281456a init + +"#]] + ); + + // The branch A, B, C are not in the workspace anymore, and we *could* signal it by removing metadata. + // But even with metadata, it still works fine. + add_stack_with_segments(&mut meta, 0, "D", StackState::InWorkspace, &["C", "B", "A"]); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทdeeae50 (โŒ‚|๐Ÿ˜) +* ยท353471f (โŒ‚|๐Ÿ˜) โ–บD <> origin/D +* ยท8a4b945 (โŒ‚|๐Ÿ˜) +* ยทe0bd0a7 (โŒ‚|๐Ÿ˜) +* ยท0b6b861 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บorigin/main <> origin/main +โ”‚ * ๐ŸŸฃbbd4ff6 โ–บorigin/D +โ”‚ * ๐ŸŸฃe5f5a87 โ–บorigin/C +โ”‚ * ๐ŸŸฃda597e8 โ–บorigin/B +โ”‚ * ๐ŸŸฃ1818c17 โ–บorigin/A +โ”œโ”€โ•ฏ +* ๐Ÿยท281456a (โŒ‚|๐Ÿ˜|โœ“) +layout: + materialized parents: deeae50: 353471f + empty chain anchors: 353471f^ +"#]] + ); + + // We let each remote on the path down own a commit so we only see one remote commit here, + // the one belonging to the last remaining associated remote tracking branch of D. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 0b6b861 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:D <> origin/Dโ‡ก3โ‡ฃ1 on 0b6b861 {0} + โ””โ”€โ”€ ๐Ÿ“™:D <> origin/Dโ‡ก3โ‡ฃ1 + โ”œโ”€โ”€ ๐ŸŸฃbbd4ff6 + โ”œโ”€โ”€ ยท353471f (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ ยท8a4b945 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ยทe0bd0a7 (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +#[test] +fn without_target_ref_or_managed_commit() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/no-target-without-ws-commit")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 4fe5a6f (origin/A) A-remote +* a62b0de (HEAD -> gitbutler/workspace, A) A2 +* 120a217 A1 +* fafd9d0 (main) init + +"#]] + ); + + add_workspace(&mut meta); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐ŸŸฃ4fe5a6f โ–บorigin/A +* ๐Ÿ‘‰ยทa62b0de (โŒ‚|๐Ÿ˜) โ–บA <> origin/A +* ยท120a217 (โŒ‚|๐Ÿ˜) +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) โ–บmain +"#]] + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:A <> origin/Aโ‡ฃ1 + โ”œโ”€โ”€ :A <> origin/Aโ‡ฃ1 + โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ4fe5a6f + โ”‚ โ”œโ”€โ”€ โ„๏ธa62b0de (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ โ„๏ธ120a217 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :main + โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) + +"#]] + ); + + let (id, ref_name) = id_at(&repo, "A"); + let ws = Workspace::from_tip( + id, + ref_name, + &*meta, + project_meta(&*meta), + standard_options(), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐ŸŸฃ4fe5a6f โ–บorigin/A +* ๐Ÿ‘‰ยทa62b0de (โŒ‚|๐Ÿ˜) โ–บA <> origin/A +* ยท120a217 (โŒ‚|๐Ÿ˜) +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) โ–บmain +"#]] + ); + + // Main can be a normal segment if there is no target ref. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:A <> origin/Aโ‡ฃ1 + โ”œโ”€โ”€ ๐Ÿ‘‰:A <> origin/Aโ‡ฃ1 + โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ4fe5a6f + โ”‚ โ”œโ”€โ”€ โ„๏ธa62b0de (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ โ„๏ธ120a217 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :main + โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +#[test] +fn without_target_ref_or_managed_commit_ambiguous() -> anyhow::Result<()> { + let (repo, mut meta) = + read_only_in_memory_scenario("ws/no-target-without-ws-commit-ambiguous")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 4fe5a6f (origin/A) A-remote +* a62b0de (HEAD -> gitbutler/workspace, B, A) A2 +* 120a217 A1 +* fafd9d0 (main) init + +"#]] + ); + + add_workspace(&mut meta); + // Without disambiguation, there is no segment name. + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐ŸŸฃ4fe5a6f โ–บorigin/A +* ๐Ÿ‘‰ยทa62b0de (โŒ‚|๐Ÿ˜) โ–บA, โ–บB <> origin/A +* ยท120a217 (โŒ‚|๐Ÿ˜) +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) โ–บmain +"#]] + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:A <> origin/Aโ‡ฃ1 + โ”œโ”€โ”€ :A <> origin/Aโ‡ฃ1 + โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ4fe5a6f + โ”‚ โ”œโ”€โ”€ โ„๏ธa62b0de (๐Ÿ˜๏ธ) โ–บB + โ”‚ โ””โ”€โ”€ โ„๏ธ120a217 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :main + โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) + +"#]] + ); + + // We can help it by adding metadata. + // Note how the selection still manages to hold on to the `A` which now gets its very own + // empty segment. + add_stack_with_segments(&mut meta, 1, "B", StackState::InWorkspace, &[]); + let (id, a_ref) = id_at(&repo, "A"); + let ws = Workspace::from_tip( + id, + a_ref.clone(), + &*meta, + project_meta(&*meta), + standard_options(), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐ŸŸฃ4fe5a6f โ–บorigin/A +* ๐Ÿ‘‰ยทa62b0de (โŒ‚|๐Ÿ˜) โ–บA, โ–บB <> origin/A +* ยท120a217 (โŒ‚|๐Ÿ˜) +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) โ–บmain +layout: + empty chain anchors: a62b0de^ +"#]] + ); + + // Main can be a normal segment if there is no target ref. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:A <> origin/A {1} + โ”œโ”€โ”€ ๐Ÿ‘‰:A <> origin/A + โ”œโ”€โ”€ ๐Ÿ“™:B + โ”‚ โ”œโ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :main + โ””โ”€โ”€ ยทfafd9d0 (๐Ÿ˜๏ธ) + +"#]] + ); + + // Finally, show the normal version with just disambiguated 'B". + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐ŸŸฃ4fe5a6f โ–บorigin/A +* ๐Ÿ‘‰ยทa62b0de (โŒ‚|๐Ÿ˜) โ–บA, โ–บB <> origin/A +* ยท120a217 (โŒ‚|๐Ÿ˜) +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) โ–บmain +layout: + empty chain anchors: a62b0de^ +"#]] + ); + + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B {1} + โ”œโ”€โ”€ ๐Ÿ“™:B + โ”‚ โ”œโ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ) โ–บA + โ”‚ โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :main + โ””โ”€โ”€ ยทfafd9d0 (๐Ÿ˜๏ธ) + +"#]] + ); + + // Order is respected + add_stack_with_segments(&mut meta, 1, "B", StackState::InWorkspace, &["A"]); + let ws = Workspace::from_tip( + id, + a_ref.clone(), + &*meta, + project_meta(&*meta), + standard_options(), + )? + .validated()?; + // The remote tracking branch must remain linked. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B {1} + โ”œโ”€โ”€ ๐Ÿ“™:B + โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:A <> origin/Aโ‡ฃ1 + โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ4fe5a6f + โ”‚ โ”œโ”€โ”€ โ„๏ธa62b0de (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ โ„๏ธ120a217 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :main + โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) + +"#]] + ); + + // Order is respected, vice-versa + add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &["B"]); + let ws = Workspace::from_tip(id, a_ref, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:A <> origin/Aโ‡ฃ1 {1} + โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:A <> origin/Aโ‡ฃ1 + โ”‚ โ””โ”€โ”€ ๐ŸŸฃ4fe5a6f + โ”œโ”€โ”€ ๐Ÿ“™:B + โ”‚ โ”œโ”€โ”€ โ„a62b0de (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ โ„120a217 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :main + โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) + +"#]] + ); + + Ok(()) +} + +#[test] +fn without_target_ref_or_managed_commit_ambiguous_with_remotes() -> anyhow::Result<()> { + let (repo, mut meta) = + read_only_in_memory_scenario("ws/no-target-without-ws-commit-ambiguous-with-remotes")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* a62b0de (HEAD -> gitbutler/workspace, origin/B, origin/A, B, A) A2 +* 120a217 A1 +* fafd9d0 (main) init + +"#]] + ); + + add_workspace(&mut meta); + // Without disambiguation, there is no segment name. + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทa62b0de (โŒ‚|๐Ÿ˜) โ–บA, โ–บB, โ–บorigin/A, โ–บorigin/B <> origin/A, origin/B +* ยท120a217 (โŒ‚|๐Ÿ˜) +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) โ–บmain <> origin/main +"#]] + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:anon: + โ”œโ”€โ”€ :anon: + โ”‚ โ”œโ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ) โ–บA, โ–บB + โ”‚ โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :main <> origin/mainโ‡ก1 + โ””โ”€โ”€ ยทfafd9d0 (๐Ÿ˜๏ธ) + +"#]] + ); + + // Remote handling is still happening when A is disambiguated by entrypoint. + let (id, a_ref) = id_at(&repo, "A"); + let ws = Workspace::from_tip( + id, + a_ref.clone(), + &*meta, + project_meta(&*meta), + standard_options(), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทa62b0de (โŒ‚|๐Ÿ˜) โ–บA, โ–บB, โ–บorigin/A, โ–บorigin/B <> origin/A, origin/B +* ยท120a217 (โŒ‚|๐Ÿ˜) +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) โ–บmain <> origin/main +"#]] + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:A <> origin/Aโ‡ก2 + โ”œโ”€โ”€ ๐Ÿ‘‰:A <> origin/Aโ‡ก2 + โ”‚ โ”œโ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ) โ–บB + โ”‚ โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :main <> origin/mainโ‡ก1 + โ””โ”€โ”€ ยทfafd9d0 (๐Ÿ˜๏ธ) + +"#]] + ); + + // The same is true when starting at a different ref. + let (id, b_ref) = id_at(&repo, "B"); + let ws = Workspace::from_tip(id, b_ref, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:B <> origin/Bโ‡ก2 + โ”œโ”€โ”€ ๐Ÿ‘‰:B <> origin/Bโ‡ก2 + โ”‚ โ”œโ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ) โ–บA + โ”‚ โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :main <> origin/mainโ‡ก1 + โ””โ”€โ”€ ยทfafd9d0 (๐Ÿ˜๏ธ) + +"#]] + ); + + // If disambiguation happens through the workspace, 'A' still shows the right remote, and 'B' as well + add_stack_with_segments(&mut meta, 1, "B", StackState::InWorkspace, &[]); + let ws = Workspace::from_tip( + id, + a_ref.clone(), + &*meta, + project_meta(&*meta), + standard_options(), + )? + .validated()?; + // NOTE: origin/A points to :5, but origin/B now also points to :5 even though it should point to :0, + // a relationship still preserved though the sibling ID. + // There is no easy way of fixing this as we'd have to know that this one connection, which can + // indirectly reach the remote tracking segment, should remain on the local tracking segment when + // reconnecting them during the segment insertion. + // This is acceptable as graph connections aren't used for this, and ultimately they still + // reach the right segment, just through one more indirection. Empty segments are 'looked through' + // as well by all algorithms for exactly that reason. + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทa62b0de (โŒ‚|๐Ÿ˜) โ–บA, โ–บB, โ–บorigin/A, โ–บorigin/B <> origin/A, origin/B +* ยท120a217 (โŒ‚|๐Ÿ˜) +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) โ–บmain <> origin/main +layout: + empty chain anchors: a62b0de^ +"#]] + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:A <> origin/A {1} + โ”œโ”€โ”€ ๐Ÿ‘‰:A <> origin/A + โ”œโ”€โ”€ ๐Ÿ“™:B <> origin/B + โ”‚ โ”œโ”€โ”€ โ„๏ธa62b0de (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ โ„๏ธ120a217 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :main <> origin/main + โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +#[test] +fn without_target_ref_with_managed_commit() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/no-target-with-ws-commit")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 3ea2742 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +| * 4fe5a6f (origin/A) A-remote +|/ +* a62b0de (A) A2 +* 120a217 A1 +* fafd9d0 (main) init + +"#]] + ); + + add_workspace(&mut meta); + // The commit is ambiguous, so there is just the entrypoint to split the segment. + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท3ea2742 (โŒ‚|๐Ÿ˜) +โ”‚ * ๐ŸŸฃ4fe5a6f โ–บorigin/A +โ”œโ”€โ•ฏ +* ยทa62b0de (โŒ‚|๐Ÿ˜) โ–บA <> origin/A +* ยท120a217 (โŒ‚|๐Ÿ˜) +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) โ–บmain +layout: + materialized parents: 3ea2742: a62b0de +"#]] + ); + // TODO: add more stacks. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:A <> origin/Aโ‡ฃ1 + โ”œโ”€โ”€ :A <> origin/Aโ‡ฃ1 + โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ4fe5a6f + โ”‚ โ”œโ”€โ”€ โ„๏ธa62b0de (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ โ„๏ธ120a217 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :main + โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) + +"#]] + ); + + let (id, ref_name) = id_at(&repo, "A"); + let ws = Workspace::from_tip( + id, + ref_name, + &*meta, + project_meta(&*meta), + standard_options(), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ยท3ea2742 (โŒ‚|๐Ÿ˜) +โ”‚ * ๐ŸŸฃ4fe5a6f โ–บorigin/A +โ”œโ”€โ•ฏ +* ๐Ÿ‘‰ยทa62b0de (โŒ‚|๐Ÿ˜) โ–บA <> origin/A +* ยท120a217 (โŒ‚|๐Ÿ˜) +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) โ–บmain +"#]] + ); + + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:A <> origin/Aโ‡ฃ1 + โ”œโ”€โ”€ ๐Ÿ‘‰:A <> origin/Aโ‡ฃ1 + โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ4fe5a6f + โ”‚ โ”œโ”€โ”€ โ„๏ธa62b0de (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ โ„๏ธ120a217 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :main + โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) + +"#]] + ); + + Ok(()) +} + +#[test] +fn workspace_commit_pushed_to_target() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/ws-commit-pushed-to-target")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 8ee08de (HEAD -> gitbutler/workspace, origin/main) GitButler Workspace Commit +* 120a217 (A) A1 +* fafd9d0 (main) init + +"#]] + ); + + add_workspace(&mut meta); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท8ee08de (โŒ‚|๐Ÿ˜|โœ“) โ–บorigin/main +* ยท120a217 (โŒ‚|๐Ÿ˜|โœ“) โ–บA +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain <> origin/main +layout: + materialized parents: 8ee08de: 120a217 +"#]] + ); + // Everything is integrated, so nothing is shown. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 120a217 + +"#]] + ); + Ok(()) +} + +#[test] +fn no_workspace_no_target_commit_under_managed_ref() -> anyhow::Result<()> { + let (repo, mut meta) = + read_only_in_memory_scenario("ws/no-ws-no-target-commit-with-managed-ref")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* dca94a4 (HEAD -> gitbutler/workspace) unmanaged +* 120a217 (A) A1 +* fafd9d0 (main) init + +"#]] + ); + + add_workspace(&mut meta); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทdca94a4 (โŒ‚|๐Ÿ˜) +* ยท120a217 (โŒ‚|๐Ÿ˜) โ–บA +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) โ–บmain +"#]] + ); + + // It's notable how hard the workspace ref tries to not own the commit + // it's under unless it's a managed commit. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:anon: + โ”œโ”€โ”€ :anon: + โ”‚ โ””โ”€โ”€ ยทdca94a4 (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ :A + โ”‚ โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :main + โ””โ”€โ”€ ยทfafd9d0 (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +#[test] +fn no_workspace_commit() -> anyhow::Result<()> { + let (repo, mut meta) = + read_only_in_memory_scenario("ws/multiple-dependent-branches-per-stack-without-ws-commit")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* cbc6713 (HEAD -> gitbutler/workspace, lane) change +* fafd9d0 (origin/main, main, lane-segment-02, lane-segment-01, lane-2-segment-02, lane-2-segment-01, lane-2) init + +"#]] + ); + + // Follow the natural order, lane first. + add_stack_with_segments( + &mut meta, + 0, + "lane", + StackState::InWorkspace, + &["lane-segment-01", "lane-segment-02"], + ); + add_stack_with_segments( + &mut meta, + 1, + "lane-2", + StackState::InWorkspace, + &["lane-2-segment-01", "lane-2-segment-02"], + ); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + // Notably we also pick up 'lane' which sits on the base. + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทcbc6713 (โŒ‚|๐Ÿ˜) โ–บlane +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บlane-2, โ–บlane-2-segment-01, โ–บlane-2-segment-02, โ–บlane-segment-01, โ–บlane-segment-02, โ–บmain, โ–บorigin/main <> origin/main +layout: + empty chain anchors: cbc6713^ fafd9d0 +"#]] + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:lane on fafd9d0 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:lane +โ”‚ โ”‚ โ””โ”€โ”€ ยทcbc6713 (๐Ÿ˜๏ธ) +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:lane-segment-01 +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:lane-segment-02 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:lane-2 on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:lane-2 + โ”œโ”€โ”€ ๐Ÿ“™:lane-2-segment-01 + โ””โ”€โ”€ ๐Ÿ“™:lane-2-segment-02 + +"#]] + ); + + // Natural order here is `lane` first, but we say we want `lane-2` first + meta.data_mut().branches.clear(); + add_stack_with_segments( + &mut meta, + 0, + "lane-2", + StackState::InWorkspace, + &["lane-2-segment-01", "lane-2-segment-02"], + ); + add_stack_with_segments( + &mut meta, + 1, + "lane", + StackState::InWorkspace, + &["lane-segment-01", "lane-segment-02"], + ); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + // the order is maintained as provided in the workspace. + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทcbc6713 (โŒ‚|๐Ÿ˜) โ–บlane +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บlane-2, โ–บlane-2-segment-01, โ–บlane-2-segment-02, โ–บlane-segment-01, โ–บlane-segment-02, โ–บmain, โ–บorigin/main <> origin/main +layout: + empty chain anchors: fafd9d0 cbc6713^ +"#]] + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:lane-2 on fafd9d0 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:lane-2 +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:lane-2-segment-01 +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:lane-2-segment-02 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:lane on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:lane + โ”‚ โ””โ”€โ”€ ยทcbc6713 (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ ๐Ÿ“™:lane-segment-01 + โ””โ”€โ”€ ๐Ÿ“™:lane-segment-02 + +"#]] + ); + Ok(()) +} + +#[test] +fn two_dependent_branches_first_merged_by_rebase() -> anyhow::Result<()> { + let (repo, mut meta) = + read_only_in_memory_scenario("ws/two-dependent-branches-first-rebased-and-merged")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 0b6b861 (origin/main, origin/A) A +| * 4f08b8d (HEAD -> gitbutler/workspace) GitButler Workspace Commit +| * da597e8 (B) B +| * 1818c17 (A) A +|/ +* 281456a (main) init + +"#]] + ); + + add_workspace(&mut meta); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท4f08b8d (โŒ‚|๐Ÿ˜) +* ยทda597e8 (โŒ‚|๐Ÿ˜) โ–บB +* ยท1818c17 (โŒ‚|๐Ÿ˜) โ–บA <> origin/A +โ”‚ * ๐ŸŸฃ0b6b861 (โœ“) โ–บorigin/A, โ–บorigin/main +โ”œโ”€โ•ฏ +* ๐Ÿยท281456a (โŒ‚|๐Ÿ˜|โœ“) โ–บmain <> origin/main +layout: + materialized parents: 4f08b8d: da597e8 +"#]] + ); + + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 281456a +โ””โ”€โ”€ โ‰ก:B on 281456a + โ”œโ”€โ”€ :B + โ”‚ โ””โ”€โ”€ ยทda597e8 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :A <> origin/Aโ‡ก1โ‡ฃ1 + โ”œโ”€โ”€ ๐ŸŸฃ0b6b861 (โœ“) + โ””โ”€โ”€ ยท1818c17 (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +#[test] +fn special_branch_names_do_not_end_up_in_segment() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/special-branches")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 8926b15 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 3686017 (main) top +* 9725482 (gitbutler/edit) middle +* fafd9d0 (gitbutler/target) init + +"#]] + ); + + add_workspace(&mut meta); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + // Standard handling after traversal and post-processing. + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท8926b15 (โŒ‚|๐Ÿ˜) +* ยท3686017 (โŒ‚|๐Ÿ˜) โ–บmain +* ยท9725482 (โŒ‚|๐Ÿ˜) โ–บgitbutler/edit +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) โ–บgitbutler/target +layout: + materialized parents: 8926b15: 3686017 +"#]] + ); + + // But special handling for workspace views. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:main + โ””โ”€โ”€ :main + โ”œโ”€โ”€ ยท3686017 (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ ยท9725482 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ยทfafd9d0 (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +#[test] +fn special_branch_do_not_allow_overly_long_segments() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/special-branches-edgecase")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 270738b (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* c59457b (A) top +* e146f13 (gitbutler/edit) middle +* 971953d (origin/main, main) M2 +* ce09734 (origin/gitbutler/target, gitbutler/target) M1 +* fafd9d0 init + +"#]] + ); + + add_workspace(&mut meta); + let mut md = meta.workspace("refs/heads/gitbutler/workspace".try_into()?)?; + let mut project_meta = md.project_meta(); + project_meta.target_ref = Some("refs/remotes/origin/gitbutler/target".try_into()?); + md.set_project_meta(project_meta); + meta.set_workspace(&md)?; + + let ws = Workspace::from_head( + &repo, + &*meta, + md.project_meta(), + // standard_options_with_extra_target(&repo, "gitbutler/target"), + standard_options(), + )? + .validated()?; + // Standard handling after traversal and post-processing. + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท270738b (โŒ‚|๐Ÿ˜) +* ยทc59457b (โŒ‚|๐Ÿ˜) โ–บA +* ยทe146f13 (โŒ‚|๐Ÿ˜) โ–บgitbutler/edit +* ยท971953d (โŒ‚|๐Ÿ˜) โ–บmain, โ–บorigin/main <> origin/main +* ยทce09734 (โŒ‚|๐Ÿ˜|โœ“) โ–บgitbutler/target, โ–บorigin/gitbutler/target <> origin/gitbutler/target +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) +layout: + materialized parents: 270738b: c59457b +"#]] + ); + + // But special handling for workspace views. Note how we don't overshoot + // and stop exactly where we have to, magically even. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/gitbutler/target on ce09734 +โ””โ”€โ”€ โ‰ก:A on ce09734 + โ”œโ”€โ”€ :A + โ”‚ โ”œโ”€โ”€ ยทc59457b (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ ยทe146f13 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :main <> origin/main + โ””โ”€โ”€ โ„๏ธ971953d (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +#[test] +fn branch_ahead_of_workspace() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/branches-ahead-of-workspace")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 790a17d (C-bottom) C2 merge commit +|\ +| * 631be19 (tmp) C1-outside2 +* | 969aaec C1-outside +|/ +| * 71dad1a (D) D2-outside +| | * c83f258 (A) A2-outside +| | | * 27c2545 (origin/A-middle, A-middle) A1-outside +| | | | * c8f73c7 (B-middle) B3-outside +| | | | * ff75b80 (intermediate-branch) B2-outside +| | | | | *-. fe6ba62 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +| | | |_|/|\ \ +| | |/| | | |/ +| | |_|_|_|/| +| |/| | | | | +| * | | | | | ed36e3b (new-name-for-D) D1 +| | | | | | * 3f7c4e6 (C) C2 +| |_|_|_|_|/ +|/| | | | | +* | | | | | b6895d7 C1 +|/ / / / / +| | | | * 2f8f06d (B) B3 +| | | |/ +| | | | * 867927f (origin/main, main) Merge branch 'B-middle' +| | | | |\ +| | | | |/ +| | | |/| +| | | * | 91bc3fc (origin/B-middle) B2 +| | | * | cf9330f B1 +| |_|/ / +|/| | | +| | | * 6e03461 Merge branch 'A' +| |_|/| +|/| |/ +| |/| +| * | a62b0de A2 +| |/ +| * 120a217 A1 +|/ +* fafd9d0 init + +"#]] + .raw() + ); + + add_workspace(&mut meta); + let ws = Workspace::from_head( + &repo, + &*meta, + project_meta(&*meta), + standard_options_with_extra_target(&repo, "main"), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ยท867927f (โŒ‚|โœ“) โ–บmain, โ–บorigin/main <> origin/main +โ”œโ”€โ•ฎ +* โ”‚ ยท6e03461 (โŒ‚|โœ“) +โ”œโ”€โ”€โ”€โ•ฎ +โ”‚ โ”‚ โ”‚ * ๐Ÿ‘‰ยทfe6ba62 (โŒ‚|๐Ÿ˜) +โ”‚ โ”‚ โ•ญโ”€โ”ผโ”€โ”ฌโ”€โ•ฎ +โ”‚ โ”‚ * โ”‚ โ”‚ โ”‚ ยทa62b0de (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ โ”‚ * โ”‚ โ”‚ โ”‚ ยท120a217 (โŒ‚|๐Ÿ˜|โœ“) +โ”œโ”€โ”€โ”€โ•ฏ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ * โ”‚ โ”‚ ยท2f8f06d (โŒ‚|๐Ÿ˜) โ–บB +โ”‚ โ”œโ”€โ”€โ”€โ•ฏ โ”‚ โ”‚ +โ”‚ * โ”‚ โ”‚ ยท91bc3fc (โŒ‚|๐Ÿ˜|โœ“) โ–บorigin/B-middle +โ”‚ * โ”‚ โ”‚ ยทcf9330f (โŒ‚|๐Ÿ˜|โœ“) +โ”œโ”€โ•ฏ โ”‚ โ”‚ +โ”‚ * โ”‚ ยท3f7c4e6 (โŒ‚|๐Ÿ˜) โ–บC +โ”‚ * โ”‚ ยทb6895d7 (โŒ‚|๐Ÿ˜) +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ โ”‚ +โ”‚ * ยทed36e3b (โŒ‚|๐Ÿ˜) โ–บnew-name-for-D +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) +layout: + materialized parents: fe6ba62: a62b0de 2f8f06d 3f7c4e6 ed36e3b +"#]] + ); + + // If it doesn't know how the workspace should be looking like, i.e. which branches are contained, + // nothing special happens. + // The branches that are outside the workspace don't exist and segments are flattened. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ2 on fafd9d0 +โ”œโ”€โ”€ โ‰ก:B on 91bc3fc +โ”‚ โ””โ”€โ”€ :B +โ”‚ โ””โ”€โ”€ ยท2f8f06d (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก:C on fafd9d0 +โ”‚ โ””โ”€โ”€ :C +โ”‚ โ”œโ”€โ”€ ยท3f7c4e6 (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ยทb6895d7 (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก:new-name-for-D on fafd9d0 + โ””โ”€โ”€ :new-name-for-D + โ””โ”€โ”€ ยทed36e3b (๐Ÿ˜๏ธ) + +"#]] + ); + + // However, when the desired workspace is set up, the traversal will include these extra tips. + add_stack_with_segments(&mut meta, 0, "A", StackState::InWorkspace, &["A-middle"]); + add_stack_with_segments(&mut meta, 1, "B", StackState::InWorkspace, &["B-middle"]); + add_stack_with_segments(&mut meta, 2, "C", StackState::InWorkspace, &["C-bottom"]); + add_stack_with_segments(&mut meta, 3, "D", StackState::InWorkspace, &[]); + + let ws = Workspace::from_head( + &repo, + &*meta, + project_meta(&*meta), + standard_options_with_extra_target(&repo, ":/init"), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ยท867927f (โŒ‚|โœ“) โ–บmain, โ–บorigin/main <> origin/main +โ”œโ”€โ•ฎ +* โ”‚ ยท6e03461 (โŒ‚|โœ“) +โ”œโ”€โ”€โ”€โ•ฎ +โ”‚ โ”‚ โ”‚ * ยทc83f258 (โŒ‚) โ–บA +โ”‚ โ”‚ โ”œโ”€โ•ฏ +โ”‚ โ”‚ โ”‚ * ยท27c2545 (โŒ‚) โ–บA-middle, โ–บorigin/A-middle <> origin/A-middle +โ”‚ โ”‚ โ”‚ โ”‚ * ยทc8f73c7 (โŒ‚) โ–บB-middle <> origin/B-middle +โ”‚ โ”‚ โ”‚ โ”‚ * ยทff75b80 (โŒ‚) โ–บintermediate-branch +โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ•ฏ +โ”‚ โ”‚ โ”‚ โ”‚ * ยท790a17d (โŒ‚) โ–บC-bottom +โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ•ฎ +โ”‚ โ”‚ โ”‚ โ”‚ * โ”‚ ยท969aaec (โŒ‚) +โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ * ยท631be19 (โŒ‚) โ–บtmp +โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ•ฏ +โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ * ยท71dad1a (โŒ‚) โ–บD +โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ * ๐Ÿ‘‰ยทfe6ba62 (โŒ‚|๐Ÿ˜) +โ”‚ โ”‚ โ•ญโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”ผโ”€โ•ฎ +โ”‚ โ”‚ * โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ ยทa62b0de (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ โ”‚ โ”œโ”€โ•ฏ โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ * โ”‚ โ”‚ โ”‚ โ”‚ ยท120a217 (โŒ‚|๐Ÿ˜|โœ“) +โ”œโ”€โ”€โ”€โ•ฏ โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ * โ”‚ ยท2f8f06d (โŒ‚|๐Ÿ˜) โ–บB +โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ โ”‚ +โ”‚ * โ”‚ โ”‚ โ”‚ ยท91bc3fc (โŒ‚|๐Ÿ˜|โœ“) โ–บorigin/B-middle +โ”‚ * โ”‚ โ”‚ โ”‚ ยทcf9330f (โŒ‚|๐Ÿ˜|โœ“) +โ”œโ”€โ•ฏ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ * ยท3f7c4e6 (โŒ‚|๐Ÿ˜) โ–บC +โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ•ฏ +โ”‚ * โ”‚ ยทb6895d7 (โŒ‚|๐Ÿ˜) +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ โ”‚ +โ”‚ * ยทed36e3b (โŒ‚|๐Ÿ˜) โ–บnew-name-for-D +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) +layout: + materialized parents: fe6ba62: a62b0de 2f8f06d 3f7c4e6 ed36e3b + empty chain anchors: 2f8f06d^ 3f7c4e6^ +"#]] + ); + + // The workspace itself contains information about the outside tips. + // We collect it no matter the location of the tip, e.g. + // - anon segment directly below the workspace commit + // - middle anon segment leading to the named branch over intermediate branches + // - middle anon segment leading to the named branch over two outgoing connections + // - except: if the segment with a known named segment in its future has a (new) name, + // we leave it and don't attempt to reconstruct the original (out-of-workspace) reference + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ2 on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A +โ”‚ โ”‚ โ”œโ”€โ”€ ยทc83f258* +โ”‚ โ”‚ โ””โ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ|โœ“) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A-middle <> origin/A-middle +โ”‚ โ”œโ”€โ”€ ยท27c2545* +โ”‚ โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ|โœ“) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on fafd9d0 {1} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ”‚ โ””โ”€โ”€ ยท2f8f06d (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B-middle <> origin/B-middle +โ”‚ โ”œโ”€โ”€ ยทc8f73c7* +โ”‚ โ”œโ”€โ”€ ยทff75b80 โ–บintermediate-branch* +โ”‚ โ”œโ”€โ”€ ยท91bc3fc (๐Ÿ˜๏ธ|โœ“) +โ”‚ โ””โ”€โ”€ ยทcf9330f (๐Ÿ˜๏ธ|โœ“) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on fafd9d0 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”‚ โ””โ”€โ”€ ยท3f7c4e6 (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:C-bottom +โ”‚ โ”œโ”€โ”€ ยท790a17d* +โ”‚ โ”œโ”€โ”€ ยท969aaec* +โ”‚ โ””โ”€โ”€ ยทb6895d7 (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก:new-name-for-D on fafd9d0 + โ””โ”€โ”€ :new-name-for-D + โ””โ”€โ”€ ยทed36e3b (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +#[test] +fn two_branches_one_advanced_two_parent_ws_commit_diverged_ttb() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario( + "ws/two-branches-one-advanced-two-parent-ws-commit-diverged-ttb", + )?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 873d056 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|\ +* | cbc6713 (advanced-lane) change +|/ +* fafd9d0 (main, lane) init +* da83717 (origin/main) disjoint remote target + +"#]] + .raw() + ); + + for (idx, name) in ["lane", "advanced-lane"].into_iter().enumerate() { + add_stack_with_segments(&mut meta, idx, name, StackState::InWorkspace, &[]); + } + + let (id, ref_name) = id_at(&repo, "lane"); + let ws = Workspace::from_tip( + id, + ref_name, + &*meta, + project_meta(&*meta), + standard_options_with_extra_target(&repo, "main"), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ยท873d056 (โŒ‚|๐Ÿ˜) +โ”œโ”€โ•ฎ +* โ”‚ ยทcbc6713 (โŒ‚|๐Ÿ˜) โ–บadvanced-lane +โ”œโ”€โ•ฏ +* ๐Ÿ‘‰๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) โ–บlane, โ–บmain <> origin/main +* ๐Ÿ๐ŸŸฃda83717 (โœ“) โ–บorigin/main +layout: + spliced chain anchors: fafd9d0^ cbc6713^ +"#]] + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:advanced-lane on fafd9d0 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:advanced-lane +โ”‚ โ””โ”€โ”€ ยทcbc6713 (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:lane on fafd9d0 {0} + โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:lane + +"#]] + ); + + let ws = Workspace::from_head( + &repo, + &*meta, + project_meta(&*meta), + standard_options_with_extra_target(&repo, "main"), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท873d056 (โŒ‚|๐Ÿ˜) +โ”œโ”€โ•ฎ +* โ”‚ ยทcbc6713 (โŒ‚|๐Ÿ˜) โ–บadvanced-lane +โ”œโ”€โ•ฏ +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บlane, โ–บmain <> origin/main +* ๐Ÿ๐ŸŸฃda83717 (โœ“) โ–บorigin/main +layout: + materialized parents: 873d056: cbc6713 fafd9d0 + empty chain anchors: fafd9d0 cbc6713^ +"#]] + ); + + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:advanced-lane on fafd9d0 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:advanced-lane +โ”‚ โ””โ”€โ”€ ยทcbc6713 (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:lane on fafd9d0 {0} + โ””โ”€โ”€ ๐Ÿ“™:lane + +"#]] + ); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท873d056 (โŒ‚|๐Ÿ˜) +โ”œโ”€โ•ฎ +* โ”‚ ยทcbc6713 (โŒ‚|๐Ÿ˜) โ–บadvanced-lane +โ”œโ”€โ•ฏ +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) โ–บlane, โ–บmain <> origin/main +* ๐Ÿ๐ŸŸฃda83717 (โœ“) โ–บorigin/main +layout: + materialized parents: 873d056: cbc6713 fafd9d0 + spliced chain anchors: fafd9d0^ cbc6713^ +"#]] + ); + + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:advanced-lane on fafd9d0 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:advanced-lane +โ”‚ โ””โ”€โ”€ ยทcbc6713 (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:lane on fafd9d0 {0} + โ””โ”€โ”€ ๐Ÿ“™:lane + +"#]] + ); + Ok(()) +} + +#[test] +fn advanced_workspace_ref() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/advanced-workspace-ref")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* a7131b1 (HEAD -> gitbutler/workspace) on-top4 +* 4d3831e (intermediate-ref) on-top3 +* 468357f on-top2-merge +|\ +| * d3166f7 (branch-on-top) on-top-sibling +|/ +* 118ddbb on-top1 +* 619d548 GitButler Workspace Commit +|\ +| * 6fdab32 (A) A1 +* | 8a352d5 (B) B1 +|/ +* bce0c5e (origin/main, main) M2 +* 3183e43 M1 + +"#]] + .raw() + ); + + add_stack_with_segments(&mut meta, 0, "A", StackState::InWorkspace, &[]); + add_stack_with_segments(&mut meta, 1, "B", StackState::InWorkspace, &[]); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทa7131b1 (โŒ‚|๐Ÿ˜) +* ยท4d3831e (โŒ‚|๐Ÿ˜) โ–บintermediate-ref +* ยท468357f (โŒ‚|๐Ÿ˜) +โ”œโ”€โ•ฎ +โ”‚ * ยทd3166f7 (โŒ‚|๐Ÿ˜) โ–บbranch-on-top +โ”œโ”€โ•ฏ +* ยท118ddbb (โŒ‚|๐Ÿ˜) +* ยท619d548 (โŒ‚|๐Ÿ˜) +โ”œโ”€โ•ฎ +* โ”‚ ยท8a352d5 (โŒ‚|๐Ÿ˜) โ–บB +โ”‚ * ยท6fdab32 (โŒ‚|๐Ÿ˜) โ–บA +โ”œโ”€โ•ฏ +* ยทbce0c5e (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บorigin/main <> origin/main +* ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“) +layout: + spliced chain anchors: 6fdab32^ 8a352d5^ +"#]] + ); + + // We show the original 'native' configuration without pruning anything, even though + // it contains the workspace commit 619d548. + // It's up to the caller to deal with this situation as the workspace now is marked differently. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ””โ”€โ”€ โ‰ก:anon: on bce0c5e {1} + โ”œโ”€โ”€ :anon: + โ”‚ โ””โ”€โ”€ ยทa7131b1 (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ :intermediate-ref + โ”‚ โ”œโ”€โ”€ ยท4d3831e (๐Ÿ˜๏ธ) + โ”‚ โ”œโ”€โ”€ ยท468357f (๐Ÿ˜๏ธ) + โ”‚ โ”œโ”€โ”€ ยท118ddbb (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ ยท619d548 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ๐Ÿ“™:B + โ””โ”€โ”€ ยท8a352d5 (๐Ÿ˜๏ธ) + +"#]] + ); + + let ws = Workspace::from_head( + &repo, + &*meta, + project_meta(&*meta), + standard_options_with_extra_target(&repo, "main"), + )? + .validated()?; + // The extra-target as would happen in the typical case would change nothing though. + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทa7131b1 (โŒ‚|๐Ÿ˜) +* ยท4d3831e (โŒ‚|๐Ÿ˜) โ–บintermediate-ref +* ยท468357f (โŒ‚|๐Ÿ˜) +โ”œโ”€โ•ฎ +โ”‚ * ยทd3166f7 (โŒ‚|๐Ÿ˜) โ–บbranch-on-top +โ”œโ”€โ•ฏ +* ยท118ddbb (โŒ‚|๐Ÿ˜) +* ยท619d548 (โŒ‚|๐Ÿ˜) +โ”œโ”€โ•ฎ +* โ”‚ ยท8a352d5 (โŒ‚|๐Ÿ˜) โ–บB +โ”‚ * ยท6fdab32 (โŒ‚|๐Ÿ˜) โ–บA +โ”œโ”€โ•ฏ +* ยทbce0c5e (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บorigin/main <> origin/main +* ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“) +layout: + spliced chain anchors: 6fdab32^ 8a352d5^ +"#]] + ); + + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ””โ”€โ”€ โ‰ก:anon: on bce0c5e {1} + โ”œโ”€โ”€ :anon: + โ”‚ โ””โ”€โ”€ ยทa7131b1 (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ :intermediate-ref + โ”‚ โ”œโ”€โ”€ ยท4d3831e (๐Ÿ˜๏ธ) + โ”‚ โ”œโ”€โ”€ ยท468357f (๐Ÿ˜๏ธ) + โ”‚ โ”œโ”€โ”€ ยท118ddbb (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ ยท619d548 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ๐Ÿ“™:B + โ””โ”€โ”€ ยท8a352d5 (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +#[test] +fn advanced_workspace_ref_single_stack() -> anyhow::Result<()> { + let (repo, mut meta) = + read_only_in_memory_scenario("ws/advanced-workspace-ref-and-single-stack")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* da912a8 (HEAD -> gitbutler/workspace) on-top4 +* 198eaf8 (intermediate-ref) on-top3 +* 3147997 on-top2-merge +|\ +| * dd7bb9a (branch-on-top) on-top-sibling +|/ +* 9785229 on-top1 +* c58f157 GitButler Workspace Commit +* 6fdab32 (A) A1 +* bce0c5e (origin/main, main) M2 +* 3183e43 M1 + +"#]] + .raw() + ); + + add_stack_with_segments(&mut meta, 0, "A", StackState::InWorkspace, &[]); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทda912a8 (โŒ‚|๐Ÿ˜) +* ยท198eaf8 (โŒ‚|๐Ÿ˜) โ–บintermediate-ref +* ยท3147997 (โŒ‚|๐Ÿ˜) +โ”œโ”€โ•ฎ +โ”‚ * ยทdd7bb9a (โŒ‚|๐Ÿ˜) โ–บbranch-on-top +โ”œโ”€โ•ฏ +* ยท9785229 (โŒ‚|๐Ÿ˜) +* ยทc58f157 (โŒ‚|๐Ÿ˜) +* ยท6fdab32 (โŒ‚|๐Ÿ˜) โ–บA +* ยทbce0c5e (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บorigin/main <> origin/main +* ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“) +layout: + empty chain anchors: 6fdab32^ +"#]] + ); + + // Here we'd show what happens if the workspace commit is somewhere in the middle + // of the segment. This is relevant for code trying to find it, which isn't done here. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ””โ”€โ”€ โ‰ก:anon: on bce0c5e {0} + โ”œโ”€โ”€ :anon: + โ”‚ โ””โ”€โ”€ ยทda912a8 (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ :intermediate-ref + โ”‚ โ”œโ”€โ”€ ยท198eaf8 (๐Ÿ˜๏ธ) + โ”‚ โ”œโ”€โ”€ ยท3147997 (๐Ÿ˜๏ธ) + โ”‚ โ”œโ”€โ”€ ยท9785229 (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ ยทc58f157 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +#[test] +fn shallow_boundary_below_workspace_lower_bound() -> anyhow::Result<()> { + let (repo, mut meta) = named_read_only_in_memory_scenario( + "special-conditions", + "shallow-workspace-boundary-below-lower-bound", + )?; + + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 00e1860 (HEAD -> gitbutler/workspace, origin/gitbutler/workspace, origin/HEAD) GitButler Workspace Commit +* 6507810 (origin/A, A) A1 +* b625665 (origin/main, main) M4 +* a821094 M3 +* bce0c5e (grafted) M2 + +"#]] + ); + + add_stack(&mut meta, 1, "A", StackState::InWorkspace); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท00e1860 (โŒ‚|๐Ÿ˜) โ–บorigin/gitbutler/workspace +* ยท6507810 (โŒ‚|๐Ÿ˜) โ–บA, โ–บorigin/A <> origin/A +* ยทb625665 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บorigin/main <> origin/main +* ยทa821094 (โŒ‚|๐Ÿ˜|โœ“) +* โ›ฐยทbce0c5e (โŒ‚|๐Ÿ˜|โœ“|โ›ฐ) +layout: + materialized parents: 00e1860: 6507810 + empty chain anchors: 6507810^ +"#]] + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on b625665 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A <> origin/A on b625665 {1} + โ””โ”€โ”€ ๐Ÿ“™:A <> origin/A + โ””โ”€โ”€ โ„๏ธ6507810 (๐Ÿ˜๏ธ) + +"#]] + ); + + Ok(()) +} + +#[test] +fn shallow_boundary_in_workspace_prevents_lower_bound() -> anyhow::Result<()> { + let (repo, mut meta) = named_read_only_in_memory_scenario( + "special-conditions", + "shallow-workspace-boundary-in-workspace", + )?; + + add_stack(&mut meta, 1, "A", StackState::InWorkspace); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท00e1860 (โŒ‚|๐Ÿ˜) โ–บorigin/gitbutler/workspace +* โ›ฐยท6507810 (โŒ‚|๐Ÿ˜|โ›ฐ) โ–บA +layout: + materialized parents: 00e1860: 6507810 + empty chain anchors: 6507810^ +"#]] + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท6507810 (๐Ÿ˜๏ธ|โ›ฐ) + +"#]] + ); + + Ok(()) +} + +#[test] +fn applied_stack_below_explicit_lower_bound() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/two-branches-one-below-base")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* e82dfab (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|\ +| * 6fdab32 (A) A1 +* | 78b1b59 (B) B1 +| | * 938e6f2 (origin/main, main) M4 +| |/ +|/| +* | f52fcec M3 +|/ +* bce0c5e M2 +* 3183e43 M1 + +"#]] + .raw() + ); + + add_workspace(&mut meta); + meta.data_mut().default_target = None; + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทe82dfab (โŒ‚|๐Ÿ˜) +โ”œโ”€โ•ฎ +* โ”‚ ยท78b1b59 (โŒ‚|๐Ÿ˜) โ–บB +* โ”‚ ยทf52fcec (โŒ‚|๐Ÿ˜) +โ”‚ * ยท6fdab32 (โŒ‚|๐Ÿ˜) โ–บA +โ”œโ”€โ•ฏ +* ยทbce0c5e (โŒ‚|๐Ÿ˜) +* ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜) +layout: + materialized parents: e82dfab: 78b1b59 6fdab32 +"#]] + ); + + // The base is automatically set to the lowest one that includes both branches, despite the target. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on bce0c5e +โ”œโ”€โ”€ โ‰ก:B on bce0c5e +โ”‚ โ””โ”€โ”€ :B +โ”‚ โ”œโ”€โ”€ ยท78b1b59 (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ยทf52fcec (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก:A on bce0c5e + โ””โ”€โ”€ :A + โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) + +"#]] + ); + + add_stack_with_segments(&mut meta, 0, "A", StackState::InWorkspace, &[]); + add_stack_with_segments(&mut meta, 1, "B", StackState::InWorkspace, &[]); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + // The same is true if stacks are known in workspace metadata. + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ยท938e6f2 (โŒ‚|โœ“) โ–บmain, โ–บorigin/main <> origin/main +โ”‚ * ๐Ÿ‘‰ยทe82dfab (โŒ‚|๐Ÿ˜) +โ”‚ โ”œโ”€โ•ฎ +โ”‚ * โ”‚ ยท78b1b59 (โŒ‚|๐Ÿ˜) โ–บB +โ”œโ”€โ•ฏ โ”‚ +* โ”‚ ยทf52fcec (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ * ยท6fdab32 (โŒ‚|๐Ÿ˜) โ–บA +โ”œโ”€โ”€โ”€โ•ฏ +* ยทbce0c5e (โŒ‚|๐Ÿ˜|โœ“) +* ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“) +layout: + materialized parents: e82dfab: 78b1b59 6fdab32 + empty chain anchors: 6fdab32^ 78b1b59^ +"#]] + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on bce0c5e +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on f52fcec {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยท78b1b59 (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on bce0c5e {0} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) + +"#]] + ); + + // Finally, if the extra-target, indicating an old stored base that isn't valid anymore. + let ws = Workspace::from_head( + &repo, + &*meta, + project_meta(&*meta), + standard_options_with_extra_target(&repo, ":/M3"), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ยท938e6f2 (โŒ‚|โœ“) โ–บmain, โ–บorigin/main <> origin/main +โ”‚ * ๐Ÿ‘‰ยทe82dfab (โŒ‚|๐Ÿ˜) +โ”‚ โ”œโ”€โ•ฎ +โ”‚ * โ”‚ ยท78b1b59 (โŒ‚|๐Ÿ˜) โ–บB +โ”œโ”€โ•ฏ โ”‚ +* โ”‚ ยทf52fcec (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ * ยท6fdab32 (โŒ‚|๐Ÿ˜) โ–บA +โ”œโ”€โ”€โ”€โ•ฏ +* ยทbce0c5e (โŒ‚|๐Ÿ˜|โœ“) +* ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“) +layout: + materialized parents: e82dfab: 78b1b59 6fdab32 + empty chain anchors: 6fdab32^ 78b1b59^ +"#]] + ); + + // The base is still adjusted so it matches the actual stacks. With the extra-target + // resolved as the target commit, the integrated `f52fcec` is at the target and is + // pruned - consistent with the no-extra-target case above. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on bce0c5e +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on f52fcec {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยท78b1b59 (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on bce0c5e {0} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) + +"#]] + ); + + Ok(()) +} + +#[test] +fn applied_stack_above_explicit_lower_bound() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/two-branches-one-above-base")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* c5587c9 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|\ +| * de6d39c (A) A1 +| * a821094 (origin/main, main) M3 +* | ce25240 (B) B1 +|/ +* bce0c5e M2 +* 3183e43 M1 + +"#]] + .raw() + ); + + add_workspace(&mut meta); + meta.data_mut().default_target = None; + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทc5587c9 (โŒ‚|๐Ÿ˜) +โ”œโ”€โ•ฎ +* โ”‚ ยทce25240 (โŒ‚|๐Ÿ˜) โ–บB +โ”‚ * ยทde6d39c (โŒ‚|๐Ÿ˜) โ–บA +โ”‚ * ยทa821094 (โŒ‚|๐Ÿ˜) โ–บmain, โ–บorigin/main <> origin/main +โ”œโ”€โ•ฏ +* ยทbce0c5e (โŒ‚|๐Ÿ˜) +* ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜) +layout: + materialized parents: c5587c9: ce25240 de6d39c +"#]] + ); + + // The base is automatically set to the lowest one that includes both branches, despite the target. + // Interestingly, A now gets to see integrated parts of the target branch. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on bce0c5e +โ”œโ”€โ”€ โ‰ก:B on bce0c5e +โ”‚ โ””โ”€โ”€ :B +โ”‚ โ””โ”€โ”€ ยทce25240 (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก:A on bce0c5e + โ”œโ”€โ”€ :A + โ”‚ โ””โ”€โ”€ ยทde6d39c (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :main <> origin/main + โ””โ”€โ”€ โ„๏ธa821094 (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +#[test] +fn dependent_branch_on_base() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/dependent-branch-on-base")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +*-. a0385a8 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|\ \ +| | * 49d4b34 (A) A1 +| |/ +|/| +| * f9e2cb7 (C2-3, C2-2, C2-1, C) C2 +| * aaa195b (C1-3, C1-2, C1-1) C1 +|/ +* 3183e43 (origin/main, main, below-below-C, below-below-B, below-below-A, below-C, below-B, below-A, B) M1 + +"#]].raw() + ); + + add_stack_with_segments( + &mut meta, + 1, + "A", + StackState::InWorkspace, + &["below-A", "below-below-A"], + ); + add_stack_with_segments( + &mut meta, + 2, + "B", + StackState::InWorkspace, + &["below-B", "below-below-B"], + ); + add_stack_with_segments( + &mut meta, + 3, + "C", + StackState::InWorkspace, + &[ + "C2-1", + "C2-2", + "C2-3", + "C1-3", + "C1-2", + "C1-1", + "below-C", + "below-below-C", + ], + ); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทa0385a8 (โŒ‚|๐Ÿ˜) +โ”œโ”€โ”ฌโ”€โ•ฎ +โ”‚ * โ”‚ ยทf9e2cb7 (โŒ‚|๐Ÿ˜) โ–บC, โ–บC2-1, โ–บC2-2, โ–บC2-3 +โ”‚ * โ”‚ ยทaaa195b (โŒ‚|๐Ÿ˜) โ–บC1-1, โ–บC1-2, โ–บC1-3 +โ”œโ”€โ•ฏ โ”‚ +โ”‚ * ยท49d4b34 (โŒ‚|๐Ÿ˜) โ–บA +โ”œโ”€โ”€โ”€โ•ฏ +* ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“) โ–บB, โ–บbelow-A, โ–บbelow-B, โ–บbelow-C, โ–บbelow-below-A, โ–บbelow-below-B, โ–บbelow-below-C, โ–บmain, โ–บorigin/main <> origin/main +layout: + materialized parents: a0385a8: 49d4b34 3183e43 f9e2cb7 + empty chain anchors: 49d4b34^ 3183e43 f9e2cb7^ +"#]] + ); + + // Both stacks will look the same, with the dependent branch inserted at the very bottom. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:below-below-B +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 3183e43 {3} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C2-1 +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C2-2 +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C2-3 +โ”‚ โ”‚ โ””โ”€โ”€ ยทf9e2cb7 (๐Ÿ˜๏ธ) +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C1-3 +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C1-2 +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C1-1 +โ”‚ โ”‚ โ””โ”€โ”€ ยทaaa195b (๐Ÿ˜๏ธ) +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-C +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:below-below-C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {1} + โ”œโ”€โ”€ ๐Ÿ“™:A + โ”‚ โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ ๐Ÿ“™:below-A + โ””โ”€โ”€ ๐Ÿ“™:below-below-A + +"#]] + ); + + let wrongly_inactive = StackState::Inactive; + add_stack_with_segments( + &mut meta, + 1, + "A", + wrongly_inactive, + &["below-A", "below-below-A"], + ); + let ws = ws.redo(&repo, &*meta, Overlay::default())?; + // The stack-id could still be found, even though `A` is wrongly marked as outside the workspace. + // Below A doesn't apply as it's marked inactive. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:below-below-B +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 3183e43 {3} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C2-1 +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C2-2 +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C2-3 +โ”‚ โ”‚ โ””โ”€โ”€ ยทf9e2cb7 (๐Ÿ˜๏ธ) +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C1-3 +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C1-2 +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C1-1 +โ”‚ โ”‚ โ””โ”€โ”€ ยทaaa195b (๐Ÿ˜๏ธ) +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-C +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:below-below-C +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +#[test] +fn remote_and_integrated_tracking_branch_on_merge() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/remote-and-integrated-tracking")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* d018f71 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +| * c1e26b0 (origin/main, main) M-advanced +|/ +| * 2181501 (origin/A) A-remote +|/ +* 1ee1e34 (A) M-base +|\ +| * efc3b77 (tmp1) X +* | c822d66 Y +|/ +* bce0c5e M2 +* 3183e43 M1 + +"#]] + .raw() + ); + add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &[]); + + let ws = Workspace::from_head( + &repo, + &*meta, + project_meta(&*meta), + standard_options().with_extra_target_commit_id(repo.rev_parse_single("origin/main")?), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 1ee1e34 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A <> origin/Aโ‡ฃ1 on 1ee1e34 {1} + โ””โ”€โ”€ ๐Ÿ“™:A <> origin/Aโ‡ฃ1 + โ””โ”€โ”€ ๐ŸŸฃ2181501 + +"#]] + ); + + Ok(()) +} + +#[test] +fn remote_and_integrated_tracking_branch_on_linear_segment() -> anyhow::Result<()> { + let (repo, mut meta) = + read_only_in_memory_scenario("ws/remote-and-integrated-tracking-linear")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 21e584f (HEAD -> gitbutler/workspace) GitButler Workspace Commit +| * 8dc508f (origin/main, main) M-advanced +|/ +| * 197ddce (origin/A) A-remote +|/ +* 081bae9 (A) M-base +* 3183e43 M1 + +"#]] + ); + add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &[]); + + let ws = Workspace::from_head( + &repo, + &*meta, + project_meta(&*meta), + standard_options().with_extra_target_commit_id(repo.rev_parse_single("origin/main")?), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 081bae9 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A <> origin/Aโ‡ฃ1 on 081bae9 {1} + โ””โ”€โ”€ ๐Ÿ“™:A <> origin/Aโ‡ฃ1 + โ””โ”€โ”€ ๐ŸŸฃ197ddce + +"#]] + ); + + Ok(()) +} + +#[test] +fn remote_and_integrated_tracking_branch_on_merge_extra_target() -> anyhow::Result<()> { + let (repo, mut meta) = + read_only_in_memory_scenario("ws/remote-and-integrated-tracking-extra-commit")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 5f2810f (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 9f47a25 (A) A-local +| * c1e26b0 (origin/main, main) M-advanced +|/ +| * 2181501 (origin/A) A-remote +|/ +* 1ee1e34 M-base +|\ +| * efc3b77 (tmp1) X +* | c822d66 Y +|/ +* bce0c5e M2 +* 3183e43 M1 + +"#]] + .raw() + ); + add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &[]); + let ws = Workspace::from_head( + &repo, + &*meta, + project_meta(&*meta), + standard_options().with_extra_target_commit_id(repo.rev_parse_single("origin/main")?), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 1ee1e34 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A <> origin/Aโ‡ก1โ‡ฃ1 on 1ee1e34 {1} + โ””โ”€โ”€ ๐Ÿ“™:A <> origin/Aโ‡ก1โ‡ฃ1 + โ”œโ”€โ”€ ๐ŸŸฃ2181501 + โ””โ”€โ”€ ยท9f47a25 (๐Ÿ˜๏ธ) + +"#]] + ); + + Ok(()) +} + +#[test] +fn unapplied_branch_on_base() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/unapplied-branch-on-base")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* a26ae77 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* fafd9d0 (origin/main, unapplied, main) init + +"#]] + ); + add_workspace(&mut meta); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทa26ae77 (โŒ‚|๐Ÿ˜) +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บunapplied, โ–บorigin/main <> origin/main +layout: + materialized parents: a26ae77: fafd9d0 +"#]] + ); + + // if the branch was never seen, it's not visible as one would expect. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 + +"#]] + ); + + // An applied branch would be present, but has no commit. + add_stack_with_segments(&mut meta, 1, "unapplied", StackState::InWorkspace, &[]); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:unapplied on fafd9d0 {1} + โ””โ”€โ”€ ๐Ÿ“™:unapplied + +"#]] + ); + + // We simulate an unapplied branch on the base by giving it branch metadata, but not listing + // it in the workspace. + add_stack_with_segments(&mut meta, 1, "unapplied", StackState::Inactive, &[]); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + + // This will be an empty workspace. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 + +"#]] + ); + + Ok(()) +} + +#[test] +fn shared_target_base_keeps_exact_target_segment_with_inactive_unapplied_branch() +-> anyhow::Result<()> { + let (repo, mut meta) = + read_only_in_memory_scenario("ws/target-shared-with-unapplied-and-origin-head")?; + add_workspace(&mut meta); + add_stack_with_segments(&mut meta, 1, "survivor", StackState::InWorkspace, &[]); + add_stack_with_segments(&mut meta, 2, "unapplied", StackState::Inactive, &[]); + + let target_ref: gix::refs::FullName = "refs/remotes/origin/main".try_into()?; + let target_head_ref: gix::refs::FullName = "refs/remotes/origin/HEAD".try_into()?; + + assert!( + repo.try_find_reference(target_ref.as_ref())?.is_some(), + "fixture must contain {target_ref}", + ); + assert!( + repo.try_find_reference(target_head_ref.as_ref())?.is_some(), + "fixture must contain {target_head_ref}", + ); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท20f65b7 (โŒ‚|๐Ÿ˜) +* ยท4ca0966 (โŒ‚|๐Ÿ˜) โ–บsurvivor +* ยทa3b180e (โŒ‚|๐Ÿ˜) +* ยทce09734 (โŒ‚|๐Ÿ˜|โœ“) โ–บbase-peer, โ–บbase-peer-1, โ–บbase-peer-2, โ–บbase-peer-3, โ–บbase-peer-4, โ–บbase-peer-5, โ–บbase-peer-6, โ–บbase-peer-7, โ–บbase-peer-8, โ–บmain, โ–บunapplied, โ–บorigin/HEAD, โ–บorigin/main <> origin/main +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) +layout: + materialized parents: 20f65b7: 4ca0966 + empty chain anchors: 4ca0966^ +"#]] + ); + let debug_graph = graph_dag(&ws); + let target_facts = ws + .commit_graph() + .layout() + .and_then(|l| l.facts_of(target_ref.as_ref())) + .filter(|facts| facts.names_segment) + .unwrap_or_else(|| { + panic!( + "expected exact target segment for existing ref {target_ref}, graph was:\n{debug_graph}" + ) + }); + + assert!( + target_facts.names_empty_segment, + "expected exact target segment to stay empty when the target rests on main, graph was:\n{debug_graph}" + ); + + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on ce09734 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:survivor on ce09734 {1} + โ””โ”€โ”€ ๐Ÿ“™:survivor + โ”œโ”€โ”€ ยท4ca0966 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ยทa3b180e (๐Ÿ˜๏ธ) + +"#]] + ); + + assert_eq!( + ws.target_ref.as_ref().map(|t| t.ref_name.as_ref()), + Some(target_ref.as_ref()), + "expected workspace target_ref to resolve from exact target segment" + ); + + // When it's applied, it will show up though. + add_stack_with_segments(&mut meta, 2, "unapplied", StackState::InWorkspace, &[]); + let ws = ws.redo(&repo, &*meta, Overlay::default())?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on ce09734 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:survivor on ce09734 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:survivor +โ”‚ โ”œโ”€โ”€ ยท4ca0966 (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ยทa3b180e (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:unapplied on ce09734 {2} + โ””โ”€โ”€ ๐Ÿ“™:unapplied + +"#]] + ); + + Ok(()) +} + +#[test] +fn unapplied_branch_on_base_no_target() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/unapplied-branch-on-base")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* a26ae77 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* fafd9d0 (origin/main, unapplied, main) init + +"#]] + ); + add_workspace(&mut meta); + remove_target(&mut meta); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทa26ae77 (โŒ‚|๐Ÿ˜) +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) โ–บmain, โ–บunapplied, โ–บorigin/main <> origin/main +layout: + materialized parents: a26ae77: fafd9d0 +"#]] + ); + + // the main branch is disambiguated by its remote reference. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:main <> origin/main + โ””โ”€โ”€ :main <> origin/main + โ””โ”€โ”€ โ„๏ธfafd9d0 (๐Ÿ˜๏ธ) โ–บunapplied + +"#]] + ); + + // The 'unapplied' branch can be added on top of that, and we make clear we want `main` as well. + add_stack_with_segments(&mut meta, 1, "unapplied", StackState::InWorkspace, &[]); + add_stack_with_segments(&mut meta, 2, "main", StackState::InWorkspace, &[]); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทa26ae77 (โŒ‚|๐Ÿ˜) +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บunapplied, โ–บorigin/main <> origin/main +layout: + materialized parents: a26ae77: fafd9d0 fafd9d0 + empty chain anchors: fafd9d0 fafd9d0 +"#]] + ); + + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:unapplied on fafd9d0 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:unapplied +โ””โ”€โ”€ โ‰ก๐Ÿ“™:main <> origin/main on fafd9d0 {2} + โ””โ”€โ”€ ๐Ÿ“™:main <> origin/main + +"#]] + ); + + // We simulate an unapplied branch on the base by giving it branch metadata, but not listing + // it in the workspace. + add_stack_with_segments(&mut meta, 1, "unapplied", StackState::Inactive, &[]); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + + // Now only `main` shows up. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:main <> origin/main on fafd9d0 {2} + โ””โ”€โ”€ ๐Ÿ“™:main <> origin/main + +"#]] + ); + + Ok(()) +} + +#[test] +fn no_ws_commit_two_branches_no_target() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/no-ws-ref-no-ws-commit-two-branches")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* bce0c5e (HEAD -> gitbutler/workspace, origin/main, main, B, A) M2 +* 3183e43 M1 + +"#]] + ); + remove_target(&mut meta); + add_stack_with_segments(&mut meta, 0, "main", StackState::InWorkspace, &[]); + add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &[]); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + // notably the target ref and local tracking branch have sibling links setup + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰โœ‚ยทbce0c5e (โŒ‚|๐Ÿ˜|โœ“) โ–บA, โ–บB, โ–บmain, โ–บorigin/main <> origin/main +layout: + empty chain anchors: bce0c5e bce0c5e +"#]] + ); + // sibling links between origin/main and main are also set + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:main <> origin/main on bce0c5e {0} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:main <> origin/main +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on bce0c5e {1} + โ””โ”€โ”€ ๐Ÿ“™:A + +"#]] + ); + Ok(()) +} + +#[test] +fn ambiguous_worktrees() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/ambiguous-worktrees")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* a5f94a2 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|\ +| * 3e01e28 (B) B +|/ +| * 8dc508f (origin/main, main) M-advanced +|/ +| * 197ddce (origin/A) A-remote +|/ +* 081bae9 (A-outside, A-inside, A) M-base +* 3183e43 M1 + +"#]] + .raw() + ); + + add_stack_with_segments(&mut meta, 0, "A", StackState::InWorkspace, &[]); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ยท8dc508f (โŒ‚|โœ“) โ–บmain, โ–บorigin/main <> origin/main +โ”‚ * ๐Ÿ‘‰ยทa5f94a2 (โŒ‚|๐Ÿ˜) +โ•ญโ”€โ”ค +โ”‚ * ยท3e01e28 (โŒ‚|๐Ÿ˜) โ–บB[๐Ÿ“wt-B-inside] +โ”œโ”€โ•ฏ +โ”‚ * ๐ŸŸฃ197ddce โ–บorigin/A +โ”œโ”€โ•ฏ +* ยท081bae9 (โŒ‚|๐Ÿ˜|โœ“) โ–บA, โ–บA-inside[๐Ÿ“wt-A-inside], โ–บA-outside[๐Ÿ“wt-A-outside] <> origin/A +* ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“) +layout: + materialized parents: a5f94a2: 3e01e28 081bae9 + empty chain anchors: 081bae9 +"#]] + ); + + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ@repo] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 081bae9 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A <> origin/Aโ‡ฃ1 on 081bae9 {0} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A <> origin/Aโ‡ฃ1 +โ”‚ โ””โ”€โ”€ ๐ŸŸฃ197ddce +โ””โ”€โ”€ โ‰ก:B[๐Ÿ“wt-B-inside] on 081bae9 + โ””โ”€โ”€ :B[๐Ÿ“wt-B-inside] + โ””โ”€โ”€ ยท3e01e28 (๐Ÿ˜๏ธ) + +"#]] + ); + + let linked_repo = gix::open_opts( + repo.path() + .parent() + .expect("repository git dir is inside the worktree") + .join("wt-B-inside"), + gix::open::Options::isolated(), + )? + .with_object_memory(); + let ws = Workspace::from_head( + &linked_repo, + &*meta, + project_meta(&*meta), + standard_options(), + )? + .validated()?; + // when the graph is built from the B linked worktree repository, the workspace remains visible but the B worktree owns the entrypoint branch + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ยท8dc508f (โŒ‚|โœ“) โ–บmain, โ–บorigin/main <> origin/main +โ”‚ * ยทa5f94a2 (โŒ‚|๐Ÿ˜) +โ•ญโ”€โ”ค +โ”‚ * ๐Ÿ‘‰ยท3e01e28 (โŒ‚|๐Ÿ˜) โ–บB[๐Ÿ“wt-B-inside@repo] +โ”œโ”€โ•ฏ +โ”‚ * ๐ŸŸฃ197ddce โ–บorigin/A +โ”œโ”€โ•ฏ +* ยท081bae9 (โŒ‚|๐Ÿ˜|โœ“) โ–บA, โ–บA-inside[๐Ÿ“wt-A-inside], โ–บA-outside[๐Ÿ“wt-A-outside] <> origin/A +* ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“) +layout: + empty chain anchors: 081bae9 +"#]] + ); + + // workspace projection should keep the linked-worktree ownership marker on the focused stack while leaving the workspace ref itself unowned + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 081bae9 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A <> origin/Aโ‡ฃ1 on 081bae9 {0} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A <> origin/Aโ‡ฃ1 +โ”‚ โ””โ”€โ”€ ๐ŸŸฃ197ddce +โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:B[๐Ÿ“wt-B-inside@repo] on 081bae9 + โ””โ”€โ”€ ๐Ÿ‘‰:B[๐Ÿ“wt-B-inside@repo] + โ””โ”€โ”€ ยท3e01e28 (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +#[test] +fn duplicate_parent_connection_from_ws_commit_to_ambiguous_branch_no_advanced_target() +-> anyhow::Result<()> { + let (repo, mut meta) = + read_only_in_memory_scenario("ws/duplicate-workspace-connection-no-target")?; + // Note that HEAD isn't actually pointing at origin/main, but twice at main + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* f18d244 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|\ +* fafd9d0 (origin/main, main, B, A) init + +"#]] + .raw() + ); + + add_stack(&mut meta, 1, "A", StackState::InWorkspace); + // Our graph is incapable of showing these two connections due to traversal + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทf18d244 (โŒ‚|๐Ÿ˜) +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บA, โ–บB, โ–บmain, โ–บorigin/main <> origin/main +layout: + materialized parents: f18d244: fafd9d0 + empty chain anchors: fafd9d0 +"#]] + ); + + // Branch should be visible in workspace once. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + +"#]] + ); + + // 'create' a new branch by metadata + add_stack(&mut meta, 2, "B", StackState::InWorkspace); + let ws = ws.redo(&repo, &*meta, Overlay::default())?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on fafd9d0 {2} + โ””โ”€โ”€ ๐Ÿ“™:B + +"#]] + ); + + // Now pretend it's stacked. + meta.data_mut().branches.clear(); + add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &["B"]); + let ws = ws.redo(&repo, &*meta, Overlay::default())?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ๐Ÿ“™:B + +"#]] + ); + + Ok(()) +} + +#[test] +fn duplicate_parent_connection_from_ws_commit_to_ambiguous_branch() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/duplicate-workspace-connection")?; + // Note that HEAD isn't actually pointing at origin/main, but twice at main + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* f18d244 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|\ +| * 12b42b0 (origin/main) RM +|/ +* fafd9d0 (main, B, A) init + +"#]] + .raw() + ); + + add_stack(&mut meta, 1, "A", StackState::InWorkspace); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทf18d244 (โŒ‚|๐Ÿ˜) +โ”‚ * ๐ŸŸฃ12b42b0 (โœ“) โ–บorigin/main +โ”œโ”€โ•ฏ +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บA, โ–บB, โ–บmain <> origin/main +layout: + materialized parents: f18d244: fafd9d0 + empty chain anchors: fafd9d0 +"#]] + ); + + // Branch should be visible in workspace once. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + +"#]] + ); + + // 'create' a new branch by metadata + add_stack(&mut meta, 2, "B", StackState::InWorkspace); + let ws = ws.redo(&repo, &*meta, Overlay::default())?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on fafd9d0 {2} + โ””โ”€โ”€ ๐Ÿ“™:B + +"#]] + ); + + // Now pretend it's stacked. + meta.data_mut().branches.clear(); + add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &["B"]); + let ws = ws.redo(&repo, &*meta, Overlay::default())?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ๐Ÿ“™:B + +"#]] + ); + + // With extra-target these cases work as well + meta.data_mut().branches.clear(); + add_stack(&mut meta, 1, "A", StackState::InWorkspace); + add_stack(&mut meta, 2, "B", StackState::InWorkspace); + let ws = Workspace::from_head( + &repo, + &*meta, + project_meta(&*meta), + standard_options_with_extra_target(&repo, "main"), + )?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on fafd9d0 {2} + โ””โ”€โ”€ ๐Ÿ“™:B + +"#]] + ); + + meta.data_mut().branches.clear(); + add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &["B"]); + let ws = Workspace::from_head( + &repo, + &*meta, + project_meta(&*meta), + standard_options_with_extra_target(&repo, "main"), + )?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ๐Ÿ“™:B + +"#]] + ); + + Ok(()) +} + +mod edit_commit { + use but_graph::Workspace; + use but_testsupport::{graph_dag, graph_workspace, visualize_commit_graph_all}; + + use super::project_meta; + use crate::walk::{add_workspace, id_at, read_only_in_memory_scenario, standard_options}; + + #[test] + fn applied_stack_below_explicit_lower_bound() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/edit-commit/simple")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 3ea2742 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* a62b0de (A) A2 +* 120a217 (gitbutler/edit) A1 +* fafd9d0 (origin/main, main) init + +"#]] + ); + + add_workspace(&mut meta); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท3ea2742 (โŒ‚|๐Ÿ˜) +* ยทa62b0de (โŒ‚|๐Ÿ˜) โ–บA +* ยท120a217 (โŒ‚|๐Ÿ˜) โ–บgitbutler/edit +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บorigin/main <> origin/main +layout: + materialized parents: 3ea2742: a62b0de +"#]] + ); + + // special branch names are skipped by default and entirely invisible. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:A on fafd9d0 + โ””โ”€โ”€ :A + โ”œโ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) + +"#]] + ); + + // However, if the HEAD points to that referenceโ€ฆ + let (id, ref_name) = id_at(&repo, "gitbutler/edit"); + let ws = Workspace::from_tip( + id, + ref_name, + &*meta, + project_meta(&*meta), + standard_options(), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ยท3ea2742 (โŒ‚|๐Ÿ˜) +* ยทa62b0de (โŒ‚|๐Ÿ˜) โ–บA +* ๐Ÿ‘‰ยท120a217 (โŒ‚|๐Ÿ˜) โ–บgitbutler/edit +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บorigin/main <> origin/main +"#]] + ); + // โ€ฆthen the segment becomes visible. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:A on fafd9d0 + โ”œโ”€โ”€ :A + โ”‚ โ””โ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ๐Ÿ‘‰:gitbutler/edit + โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) + } +} + +/// Complex merge history with origin/main as the target branch. +/// This simulates a real-world scenario where: +/// - origin/main has multiple merged PRs with complex merge history +/// - A local workspace branch exists with uncommitted work +/// - The local stack branches off from an earlier point in history (nightly/0.5.1754) +#[test] +fn complex_merge_history_with_origin_main_target() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/complex-merge-origin-main")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 4d53bb1 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 4eaff93 (reimplement-insert-blank-commit, reconstructed-insert-blank-commit-branch, local-stack) composability improvements +* d19db1d rename reword_commit to commit_reword +* fb0a67e Reimplement insert blank commit +| * e7e93d6 (origin/main, main) Merge pull request #11567 from gitbutlerapp/jt/uhunk2 +| |\ +| | * eadc96a (jt-uhunk2) Address Copilot review +| | * 8db8b43 refactor +| | * 0aa7094 rub: uncommitted hunk to unassigned area +| | * 28a0336 id: ensure that branch IDs work +| |/ +|/| +| * 49b28a4 (tag: nightly/0.5.1755) refactor-remove-unused-css-variables (#11576) +| * d627ca0 Merge pull request #11571 +| |\ +| | * d62ab55 (pr-11571) Restrict visibility of some functions +| |/ +| * 4ad4354 Merge pull request #11574 from Byron/fix +|/| +| * 5de9f4e (byron-fix) Adjust type of ui.check_for_updates_interval_in_seconds +* | 68e62aa (tag: nightly/0.5.1754) Merge pull request #11573 +|\ \ +| |/ +|/| +| * 2d02c78 (pr-11573) fix kiril reword example +|/ +* 322cb14 base +* fafd9d0 init + +"#]].raw() + ); + + // Add workspace with origin/main as target (not origin/main) + add_workspace(&mut meta); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ10 on 68e62aa +โ””โ”€โ”€ โ‰ก:anon: on 68e62aa + โ””โ”€โ”€ :anon: + โ”œโ”€โ”€ ยท4eaff93 (๐Ÿ˜๏ธ) โ–บlocal-stack, โ–บreconstructed-insert-blank-commit-branch, โ–บreimplement-insert-blank-commit + โ”œโ”€โ”€ ยทd19db1d (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ยทfb0a67e (๐Ÿ˜๏ธ) + +"#]] + ); + + // Also add the local stack as a workspace stack + add_stack_with_segments( + &mut meta, + 0, + "reimplement-insert-blank-commit", + StackState::InWorkspace, + &["reconstructed-insert-blank-commit-branch"], + ); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ10 on 68e62aa +โ””โ”€โ”€ โ‰ก๐Ÿ“™:reimplement-insert-blank-commit on 68e62aa {0} + โ”œโ”€โ”€ ๐Ÿ“™:reimplement-insert-blank-commit + โ””โ”€โ”€ ๐Ÿ“™:reconstructed-insert-blank-commit-branch + โ”œโ”€โ”€ ยท4eaff93 (๐Ÿ˜๏ธ) โ–บlocal-stack + โ”œโ”€โ”€ ยทd19db1d (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ยทfb0a67e (๐Ÿ˜๏ธ) + +"#]] + ); + + Ok(()) +} + +#[test] +fn reproduce_12146() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/reproduce-12146")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* d77ecda (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|\ +| * 7163661 (B) New commit on branch B +|/ +* 81d4e38 (A) add A +* e32cf47 (origin/main, main) add M + +"#]] + .raw() + ); + + add_stack_with_segments(&mut meta, 0, "A", StackState::InWorkspace, &[]); + add_stack_with_segments(&mut meta, 1, "B", StackState::InWorkspace, &[]); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทd77ecda (โŒ‚|๐Ÿ˜) +โ”œโ”€โ•ฎ +โ”‚ * ยท7163661 (โŒ‚|๐Ÿ˜) โ–บB +โ”œโ”€โ•ฏ +* ยท81d4e38 (โŒ‚|๐Ÿ˜) โ–บA +* ๐Ÿยทe32cf47 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บorigin/main <> origin/main +layout: + materialized parents: d77ecda: 7163661 81d4e38 + empty chain anchors: 81d4e38^ 7163661^ +"#]] + ); + + // The sibling ID is not set, and we see only two stacks: B owns 7163661, + // and both A and B include the shared base commit 81d4e38 (A only has 81d4e38). + let ws = &ws; + snapbox::assert_data_eq!( + graph_workspace(ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e32cf47 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on e32cf47 {0} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ”‚ โ””โ”€โ”€ ยท81d4e38 (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on e32cf47 {1} + โ””โ”€โ”€ ๐Ÿ“™:B + โ”œโ”€โ”€ ยท7163661 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ยท81d4e38 (๐Ÿ˜๏ธ) + +"#]] + ); + + Ok(()) +} + +/// A stack where a local merge commit at the bottom is already integrated into +/// origin/main (the same PR was merged upstream). The merge commit is kept +/// because it is above the workspace target โ€” integrated commits are only +/// pruned at or below the target. +#[test] +fn integrated_merge_at_bottom_is_kept() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/integrated-merge-at-bottom")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 732604f (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 66ea651 (local-stack) D +* e5a88a7 C +* 0b3ccaf Merge pull request #1 from fix +|\ +| | * f46830d (origin/main, main) Merge pull request #1 from fix +| |/| +|/|/ +| * f5f42e0 (fix) fix +|/ +* fafd9d0 init + +"#]] + .raw() + ); + + add_stack_with_segments(&mut meta, 0, "local-stack", StackState::InWorkspace, &[]); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on f5f42e0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:local-stack on fafd9d0 {0} + โ””โ”€โ”€ ๐Ÿ“™:local-stack + โ”œโ”€โ”€ ยท66ea651 (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ ยทe5a88a7 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ยท0b3ccaf (๐Ÿ˜๏ธ) + +"#]] + ); + + Ok(()) +} + +/// A branch that has a commit, merges main into itself, then has another commit. +/// The fork-point approach finds the original divergence point, so all branch +/// commits (including those below the merge-from-main) remain visible. +#[test] +fn merge_from_main_keeps_all_branch_commits() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/merge-from-main-in-branch")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 891e228 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* cd76046 (my-branch) branch-commit-2 +* f8ff9a3 Merge main into my-branch +|\ +| * ef56fab (origin/main, main) main-advance +* | 6f65768 branch-commit-1 +|/ +* fafd9d0 init + +"#]] + .raw() + ); + + add_stack_with_segments(&mut meta, 0, "my-branch", StackState::InWorkspace, &[]); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท891e228 (โŒ‚|๐Ÿ˜) +* ยทcd76046 (โŒ‚|๐Ÿ˜) โ–บmy-branch +* ยทf8ff9a3 (โŒ‚|๐Ÿ˜) +โ”œโ”€โ•ฎ +* โ”‚ ยท6f65768 (โŒ‚|๐Ÿ˜) +โ”‚ * ยทef56fab (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บorigin/main <> origin/main +โ”œโ”€โ•ฏ +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) +layout: + materialized parents: 891e228: cd76046 + empty chain anchors: cd76046^ +"#]] + ); + + // The fork-point approach correctly finds the original divergence point (fafd9d0) + // instead of the moved merge base (ef56fab), so all 3 branch commits are visible: + // branch-commit-2, the merge commit, and branch-commit-1. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on ef56fab +โ””โ”€โ”€ โ‰ก๐Ÿ“™:my-branch on fafd9d0 {0} + โ””โ”€โ”€ ๐Ÿ“™:my-branch + โ”œโ”€โ”€ ยทcd76046 (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ ยทf8ff9a3 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ยท6f65768 (๐Ÿ˜๏ธ) + +"#]] + ); + + Ok(()) +} + +/// A branch whose commits are integrated (reachable from origin/main after +/// upstream merged them) but the workspace target hasn't advanced yet. +/// Integrated commits above the target must be kept so `integrate_upstream` +/// can detect them. Once the target advances past them, they are pruned. +#[test] +fn integrated_commits_above_target_are_kept() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/integrated-above-target")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 7786959 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +| * 1af5972 (origin/main, main) Merge branch my-branch +| |\ +| |/ +|/| +* | 312f819 (my-branch) B +* | e255adc A +|/ +* fafd9d0 init + +"#]] + .raw() + ); + + let init_id = repo.rev_parse_single("main~1")?.detach(); + add_workspace_with_target(&mut meta, init_id); + add_stack_with_segments(&mut meta, 0, "my-branch", StackState::InWorkspace, &[]); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + // With the target at "init", A and B are above the target and should be + // kept even though they are marked integrated. + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:my-branch on fafd9d0 {0} + โ””โ”€โ”€ ๐Ÿ“™:my-branch + โ”œโ”€โ”€ ยท312f819 (๐Ÿ˜๏ธ|โœ“) + โ””โ”€โ”€ ยทe255adc (๐Ÿ˜๏ธ|โœ“) + +"#]] + ); + + // Now advance the target to origin/main (which includes the merge). + // Both commits are at or below the new target and should be pruned, + // but the metadata-tracked branch entry is preserved. + let main_id = repo.rev_parse_single("main")?.detach(); + add_workspace_with_target(&mut meta, main_id); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 312f819 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:my-branch on 312f819 {0} + โ””โ”€โ”€ ๐Ÿ“™:my-branch + +"#]] + ); + + let ws = Workspace::from_head( + &repo, + &*meta, + project_meta(&*meta), + standard_options().with_hard_limit(usize::MAX), + )? + .validated()?; + assert!( + !ws.hard_limit_hit(), + "pruning integrated tips should not report a hard-limit traversal stop" + ); + + Ok(()) +} + +/// Regression: an old branch applied below the stored target drags the workspace base +/// below it, exposing the integrated trunk between base and target. Those commits must be +/// pruned even though `origin/main` has advanced past the target - which previously +/// disabled integrated-commit pruning entirely. +#[test] +fn integrated_commits_below_target_pruned_when_upstream_ahead() -> anyhow::Result<()> { + let (repo, mut meta) = + read_only_in_memory_scenario("ws/integrated-below-target-upstream-ahead")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* aca392b (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|\ +| * f458f7d (old-branch) O +* | f5055a1 (my-branch) W +| | * 7282cb5 (origin/main, main) upstream +| |/ +|/| +* | 2121f9c target +|/ +* 322cb14 base +* fafd9d0 init + +"#]] + .raw() + ); + + // Stored target is 'target' (main~1); origin/main is one commit ahead at 'upstream'. + let target_id = repo.rev_parse_single("main~1")?.detach(); + add_workspace_with_target(&mut meta, target_id); + add_stack_with_segments(&mut meta, 0, "my-branch", StackState::InWorkspace, &[]); + add_stack_with_segments(&mut meta, 1, "old-branch", StackState::InWorkspace, &[]); + + // 'W' and 'O' are above/beside the target and kept; 'target' and 'base' are + // integrated and at or below the target, so they are pruned from both stacks + // even though origin/main has advanced past the target. + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 322cb14 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:my-branch on 2121f9c {0} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:my-branch +โ”‚ โ””โ”€โ”€ ยทf5055a1 (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:old-branch on 322cb14 {1} + โ””โ”€โ”€ ๐Ÿ“™:old-branch + โ””โ”€โ”€ ยทf458f7d (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +/// A branch that forks below the target and catches up via `merge origin/main`, so the +/// target enters X only through the merge's second parent (off X's first-parent spine). +/// X is floored at its fork point - where its own first-parent work meets the trunk - so +/// the trunk below the fork (`c1`, `init`) is pruned, leaving X's own commits. +#[test] +fn catchup_merge_below_target_floors_at_fork() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/catchup-merge-leak")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 254106a (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* f210f41 (X) x2 +* f8cd0ce catch up to origin/main +|\ +| * 0975125 (origin/main, main) U +| * a7db886 B +| * d263f88 T +| * 8bd7dc1 c2 +* | 4eec82a x1 +|/ +* b4bd43f c1 +* fafd9d0 init + +"#]] + .raw() + ); + + // Stored target is 'T' (main~2); origin/main is two commits ahead at 'U'. + let target_id = repo.rev_parse_single("main~2")?.detach(); + add_workspace_with_target(&mut meta, target_id); + add_stack_with_segments(&mut meta, 0, "X", StackState::InWorkspace, &[]); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on d263f88 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:X on b4bd43f {0} + โ””โ”€โ”€ ๐Ÿ“™:X + โ”œโ”€โ”€ ยทf210f41 (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ ยทf8cd0ce (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ยท4eec82a (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +/// A non-workspace ref (tag) points at the workspace commit itself, +/// and that ref is used as the entrypoint for traversal. +/// This verifies that the entrypoint is correctly identified even when it +/// coincides with the workspace commit. +#[test] +fn entrypoint_on_workspace_commit() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/entrypoint-on-workspace-commit")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 3ea2742 (HEAD -> gitbutler/workspace, tag: my-tag) GitButler Workspace Commit +* a62b0de (A) A2 +* 120a217 A1 +* fafd9d0 (origin/main, main) init + +"#]] + ); + + add_workspace(&mut meta); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท3ea2742 (โŒ‚|๐Ÿ˜) โ–บtags/my-tag +* ยทa62b0de (โŒ‚|๐Ÿ˜) โ–บA +* ยท120a217 (โŒ‚|๐Ÿ˜) +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บorigin/main <> origin/main +layout: + materialized parents: 3ea2742: a62b0de +"#]] + ); + + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:A on fafd9d0 + โ””โ”€โ”€ :A + โ”œโ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) + +"#]] + ); + + // Now traverse from the tag that points at the workspace commit. + let (id, name) = id_at(&repo, "my-tag"); + let ws = Workspace::from_tip(id, name, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท3ea2742 (โŒ‚|๐Ÿ˜) โ–บtags/my-tag +* ยทa62b0de (โŒ‚|๐Ÿ˜) โ–บA +* ยท120a217 (โŒ‚|๐Ÿ˜) +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บorigin/main <> origin/main +layout: + materialized parents: 3ea2742: a62b0de +"#]] + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:A on fafd9d0 + โ””โ”€โ”€ :A + โ”œโ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +/// A workspace where the local branch was deleted, leaving only origin/A. +/// The workspace commit still references the old branch tip as a parent. +/// This probes whether a remote-only segment at the top of a stack is handled +/// correctly (previously protected by front-pruning workaround). +#[test] +fn remote_only_stack_top() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/remote-only-stack-top")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 3ea2742 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* a62b0de (origin/A) A2 +* 120a217 A1 +* fafd9d0 (origin/main, main) init + +"#]] + ); + + add_workspace(&mut meta); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท3ea2742 (โŒ‚|๐Ÿ˜) +* ยทa62b0de (โŒ‚|๐Ÿ˜) โ–บorigin/A +* ยท120a217 (โŒ‚|๐Ÿ˜) +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บorigin/main <> origin/main +layout: + materialized parents: 3ea2742: a62b0de +"#]] + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:anon: on fafd9d0 + โ””โ”€โ”€ :anon: + โ”œโ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +/// A local branch B is stacked on top of a remote-only origin/A (no local A). +/// origin/A's commits are on the first-parent path between B and main. +/// This probes whether a remote-only segment appearing after a local segment +/// in a stack is handled correctly (previously protected by tail-pruning workaround). +#[test] +fn remote_trailing_local_stack() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/remote-trailing-local-stack")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 5638b41 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* cb7021b (B) B2 +* ce3278a B1 +* a62b0de (origin/A) A2 +* 120a217 A1 +* fafd9d0 (origin/main, main) init + +"#]] + ); + + add_workspace(&mut meta); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿยทfafd9d0 (โŒ‚|โœ“) โ–บmain, โ–บorigin/main <> origin/main +* ๐Ÿ‘‰ยท5638b41 (โŒ‚|๐Ÿ˜) +* ยทcb7021b (โŒ‚|๐Ÿ˜) โ–บB +* ๐Ÿยทce3278a (โŒ‚|๐Ÿ˜) +layout: + materialized parents: 5638b41: cb7021b +"#]] + ); + // this is a weird state as the target is actually disjoint from the workspace - it appears empty now + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on cb7021b + +"#]] + ); + Ok(()) +} + +/// A workspace that merges a remote-only branch (origin/A) with no local counterpart. +/// Unlike `remote_only_stack_top` where the local was deleted after workspace creation, +/// here the local never existed. This tests whether the `is_pruned` check correctly +/// handles a stack that starts with a remote-only segment. +#[test] +fn remote_ref_as_stack_top() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/remote-ref-as-stack-top")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 21bff1f (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|\ +| * a62b0de (origin/A) A2 +| * 120a217 A1 +|/ +* fafd9d0 (origin/main, main) init + +"#]] + .raw() + ); + + add_workspace(&mut meta); + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท21bff1f (โŒ‚|๐Ÿ˜) +โ”œโ”€โ•ฎ +โ”‚ * ยทa62b0de (โŒ‚|๐Ÿ˜) โ–บorigin/A +โ”‚ * ยท120a217 (โŒ‚|๐Ÿ˜) +โ”œโ”€โ•ฏ +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บorigin/main <> origin/main +layout: + materialized parents: 21bff1f: fafd9d0 a62b0de +"#]] + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 +โ””โ”€โ”€ โ‰ก:anon: on fafd9d0 + โ””โ”€โ”€ :anon: + โ”œโ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +#[test] +fn ws_ref_no_ws_commit_stack_branch_on_same_commit() -> anyhow::Result<()> { + // The workspace ref rests on the same commit as a metadata stack branch, without a managed + // workspace commit. The stack branch names the traversal segment, dropping the special + // workspace ref from the commit's refs โ€” the build must still splice the empty workspace + // segment (it used to skip it, making the projection fail to find the workspace upstream). + let (repo, mut meta) = read_only_in_memory_scenario("ws/just-init-with-branches")?; + add_stack_with_segments(&mut meta, 0, "A", StackState::InWorkspace, &[]); + let ws_ref: gix::refs::FullName = "refs/heads/gitbutler/workspace".try_into()?; + let ws_tip = repo.find_reference(ws_ref.as_ref())?.peel_to_id()?; + let ws = Workspace::from_tip( + ws_tip, + ws_ref, + &*meta, + but_core::ref_metadata::ProjectMeta::default(), + standard_options(), + )?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“! +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A {0} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยทfafd9d0 (๐Ÿ˜๏ธ) โ–บB, โ–บC, โ–บD, โ–บE, โ–บF, โ–บmain[๐ŸŒณ] + +"#]] + ); + Ok(()) +} + +mod applied_main { + //! The applied-main corner specs (see graph-unify-plan.md "MAIN AS AN ORDINARY BRANCH"): + //! what the projection currently says when metadata declares the target's LOCAL tracking + //! branch as a workspace stack. These renders are the baseline for lifting the + //! target-local apply-blocker โ€” behavior changes must show up here first. + use super::*; + + /// (a) main rests at the workspace base and is not a workspace-commit parent: + /// membership comes from metadata alone, via the empty-lane machinery. + #[test] + fn at_base() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/applied-main-at-base")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 5edc691 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|\ +| * f57c528 (B) B1 +* | 49d4b34 (A) A1 +|/ +* 3183e43 (origin/main, main) M1 + +"#]] + .raw() + ); + + add_stack_with_segments(&mut meta, 0, "A", StackState::InWorkspace, &[]); + add_stack_with_segments(&mut meta, 1, "B", StackState::InWorkspace, &[]); + add_stack_with_segments(&mut meta, 2, "main", StackState::InWorkspace, &[]); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + let ws = &ws; + snapbox::assert_data_eq!( + graph_workspace(ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {0} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ”‚ โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยทf57c528 (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:main <> origin/main on 3183e43 {2} + โ””โ”€โ”€ ๐Ÿ“™:main <> origin/main + +"#]] + ); + Ok(()) + } + + /// (b) main has its own commit ahead of origin/main and is the workspace commit's first + /// parent โ€” a lane with commits, ahead of its remote like any branch. + #[test] + fn ahead_of_remote() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/applied-main-ahead")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* e8484be (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|\ +| * 49d4b34 (A) A1 +* | bce0c5e (main) M2 +|/ +* 3183e43 (origin/main) M1 + +"#]] + .raw() + ); + + add_stack_with_segments(&mut meta, 0, "main", StackState::InWorkspace, &[]); + add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &[]); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + let ws = &ws; + snapbox::assert_data_eq!( + graph_workspace(ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:main <> origin/mainโ‡ก1 on 3183e43 {0} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:main <> origin/mainโ‡ก1 +โ”‚ โ””โ”€โ”€ ยทbce0c5e (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) + } + + /// (c) main is a workspace-commit parent at the base while origin/main moved ahead: + /// the applied lane is behind its remote. + #[test] + fn behind_remote() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/applied-main-behind")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 1943cdc (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|\ +| * 49d4b34 (A) A1 +|/ +| * 73c46a6 (origin/main) RM1 +|/ +* 3183e43 (main) M1 + +"#]] + .raw() + ); + + add_stack_with_segments(&mut meta, 0, "main", StackState::InWorkspace, &[]); + add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &[]); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + let ws = &ws; + snapbox::assert_data_eq!( + graph_workspace(ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:main <> origin/mainโ‡ฃ1 on 3183e43 {0} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:main <> origin/mainโ‡ฃ1 +โ”‚ โ””โ”€โ”€ ๐ŸŸฃ73c46a6 (โœ“) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) + } + + /// (d) main (and its remote) advanced above A's fork point: the stale-fork corner. + /// + /// RULING (Mattias, 2026-07-04): the target's local is exempt from integrated pruning when + /// metadata applies it as a lane โ€” caught up with the target, ALL its commits are + /// integrated by definition, so pruning would empty the lane and slide its base to the + /// workspace lower bound. The applied lane keeps its commits: it IS the base indicator, + /// and its base stays correct by construction. + #[test] + fn above_stack_fork_point() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/applied-main-above-fork")?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* e8484be (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|\ +| * 49d4b34 (A) A1 +* | bce0c5e (origin/main, main) M2 +|/ +* 3183e43 M1 + +"#]] + .raw() + ); + + add_stack_with_segments(&mut meta, 0, "main", StackState::InWorkspace, &[]); + add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &[]); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + let ws = &ws; + snapbox::assert_data_eq!( + graph_workspace(ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:main <> origin/main on 3183e43 {0} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:main <> origin/main +โ”‚ โ””โ”€โ”€ โ„๏ธbce0c5e (๐Ÿ˜๏ธ|โœ“) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) + } +} diff --git a/crates/but/tests/fixtures/scenario/two-stacks-second-double.sh b/crates/but/tests/fixtures/scenario/two-stacks-second-double.sh new file mode 100644 index 00000000000..776a43750e9 --- /dev/null +++ b/crates/but/tests/fixtures/scenario/two-stacks-second-double.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +set -eu -o pipefail + +source "${BASH_SOURCE[0]%/*}/shared.sh" + +### General Description + +# Two single-commit stacks anchored on DIFFERENT bases: A on the merge base M, B one +# commit back on M's parent Z. The moving_multiple test moves both commits out, emptying +# both stacks onto distinct bases โ€” so they map to distinct workspace-commit parents and +# exercise parent-index stack ordering (not the same-base fallback). +git-init-frozen +commit-file Z +commit-file M +setup_target_to_match_main + +git checkout -b A + commit-file A +git checkout -b B main~1 + commit-file B +create_workspace_commit_once A B From 6152fe6c4027b5bc317dd3aebee36f3eccc65519 Mon Sep 17 00:00:00 2001 From: Mattias Granlund Date: Sun, 12 Jul 2026 11:30:05 +0300 Subject: [PATCH 04/11] Add the graph builder: gather facts, plan, then mint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old pipeline minted segments mid-traversal and then ran ~10 repair passes to fix what earlier passes broke. The builder instead decides everything as data before anything exists: facts (pure reads) โ”€โ–ถ chain plan (pure data) โ”€โ–ถ mint โ”€โ–ถ stored ref layout - facts: boundaries and shapes read off the CommitGraph โ€” no decisions - plan: chains from workspace metadata, naming, remote claims, all data - mint: build-private rows created with final names/extents/connections; nothing mutates rows after creation, so the repair-pass bug class cannot exist - the ref layout โ€” every surfaced ref with its position over the graph โ€” is the builder's lasting output, stored on the CommitGraph Every repair pass this replaces was census-gated to zero over the full test corpus before it went (an env-gated counter at the pass's work site; delete only at zero, and only with a by-construction argument). Unwired until the switch-over. --- crates/but-graph/src/build/ad_hoc.rs | 98 ++ crates/but-graph/src/build/facts.rs | 296 +++++ crates/but-graph/src/build/layout.rs | 638 ++++++++++ crates/but-graph/src/build/materialize.rs | 323 +++++ crates/but-graph/src/build/mod.rs | 815 +++++++++++++ crates/but-graph/src/build/plan.rs | 895 ++++++++++++++ crates/but-graph/src/build/remote_segments.rs | 401 +++++++ crates/but-graph/src/build/remotes.rs | 207 ++++ crates/but-graph/src/build/segment_data.rs | 1059 +++++++++++++++++ crates/but-graph/src/ref_layout.rs | 385 ++++++ 10 files changed, 5117 insertions(+) create mode 100644 crates/but-graph/src/build/ad_hoc.rs create mode 100644 crates/but-graph/src/build/facts.rs create mode 100644 crates/but-graph/src/build/layout.rs create mode 100644 crates/but-graph/src/build/materialize.rs create mode 100644 crates/but-graph/src/build/mod.rs create mode 100644 crates/but-graph/src/build/plan.rs create mode 100644 crates/but-graph/src/build/remote_segments.rs create mode 100644 crates/but-graph/src/build/remotes.rs create mode 100644 crates/but-graph/src/build/segment_data.rs create mode 100644 crates/but-graph/src/ref_layout.rs diff --git a/crates/but-graph/src/build/ad_hoc.rs b/crates/but-graph/src/build/ad_hoc.rs new file mode 100644 index 00000000000..36e1dc75860 --- /dev/null +++ b/crates/but-graph/src/build/ad_hoc.rs @@ -0,0 +1,98 @@ +//! Ad-hoc/single-branch mode: decide which persisted GitButler-created branch ordering +//! applies to the checked-out ref. The order is a CHAIN โ€” it threads through the same +//! plan machinery as workspace metadata stacks; nothing is rewritten after the fact. + +use anyhow::Context as _; +use but_core::RefMetadata; +use gix::reference::Category; + +use crate::walk::overlay::{OverlayMetadata, OverlayRepo}; + +/// The persisted ad-hoc branch ordering, split by consumer. +#[derive(Default)] +pub(crate) struct AdHocOrders { + /// The complete persisted order โ€” the projection walks it to keep one stack across + /// commit-owning branch boundaries. + pub full_orders: Vec>, + /// Contiguous same-tip runs (two or more refs on one commit) โ€” the chains whose + /// earlier members become empty segments above the run's bottom owner. + pub same_tip_chains: Vec>, +} + +/// In ad-hoc/single-branch mode, return the persisted GitButler-created branch ordering +/// that applies to the checked-out ref. Empty when no ordering applies. +pub(crate) fn ad_hoc_branch_stack_upgrades( + entrypoint_ref: Option<&gix::refs::FullName>, + entrypoint_present: bool, + repo: &OverlayRepo<'_>, + meta: &OverlayMetadata<'_, T>, +) -> anyhow::Result { + let Some(entrypoint_ref) = entrypoint_ref.cloned() else { + return Ok(AdHocOrders::default()); + }; + if entrypoint_ref.category() != Some(Category::LocalBranch) { + return Ok(AdHocOrders::default()); + } + if !entrypoint_present { + return Ok(AdHocOrders::default()); + } + let Some(branch_order) = meta.branch_stack_order(entrypoint_ref.as_ref())? else { + return Ok(AdHocOrders::default()); + }; + let mut existing_ordered_refs = Vec::new(); + for branch in branch_order { + if branch.category() != Some(Category::LocalBranch) { + continue; + } + let Some(mut reference) = repo.try_find_reference(branch.as_ref()).with_context(|| { + format!( + "failed to find ordered ad-hoc branch '{}'", + branch.shorten() + ) + })? + else { + continue; + }; + let commit_id = reference + .peel_to_id() + .with_context(|| { + format!( + "failed to peel ordered ad-hoc branch '{}'", + branch.shorten() + ) + })? + .detach(); + existing_ordered_refs.push((branch, commit_id)); + } + if existing_ordered_refs.len() < 2 { + return Ok(AdHocOrders::default()); + } + + // A persisted stack can cross commit-owning branch boundaries: an empty branch + // ordered above a commit-owning one must stay an empty segment above it, not claim + // its commit. So the chains are the CONTIGUOUS same-tip runs, each rebuilt + // independently โ€” while the projection gets the complete order to thread the runs + // into one stack. + let full_order: Vec<_> = existing_ordered_refs + .iter() + .map(|(branch, _)| branch.clone()) + .collect(); + let mut same_tip_groups: Vec<(gix::ObjectId, Vec)> = Vec::new(); + for (branch, commit_id) in existing_ordered_refs { + if let Some((group_commit_id, branches)) = same_tip_groups.last_mut() + && *group_commit_id == commit_id + { + branches.push(branch); + } else { + same_tip_groups.push((commit_id, vec![branch])); + } + } + Ok(AdHocOrders { + full_orders: vec![full_order], + same_tip_chains: same_tip_groups + .into_iter() + .filter(|(_, refs)| refs.len() >= 2) + .map(|(_, refs)| refs) + .collect(), + }) +} diff --git a/crates/but-graph/src/build/facts.rs b/crates/but-graph/src/build/facts.rs new file mode 100644 index 00000000000..e31ae233726 --- /dev/null +++ b/crates/but-graph/src/build/facts.rs @@ -0,0 +1,296 @@ +//! Phase 1 of gather-then-build: pure boundary/shape reads over the commit-graph. +//! +//! The scans here touch every commit in the window, so they run in HANDLE space +//! (plain node indices with dense per-node vectors) and convert to ids only at the [`Facts`] +//! boundary โ€” `by_id` hashing and per-call parent allocation dominate profiles otherwise. + +use super::{IdMap, IdSet, disambiguated_ref_at}; + +/// Everything the build decides BEFORE any segment exists โ€” pure facts over the commit graph, +/// ref positions, and metadata. Phase 1 of gather-then-build: the materialization and every +/// later pass read these; nothing here reads a segment. +pub(super) struct Facts { + /// The commit set the LOCAL segments span. + pub(super) in_set: IdSet, + /// Is the checked-out workspace commit a real GitButler-managed merge? + pub(super) ws_is_managed_merge: bool, + /// Stored/extra target positions (and explicit seeds). Each must START a segment, + /// because the projection recognizes a remembered target position only at a segment + /// start โ€” one hiding mid-segment loses the remembered base, and with it the + /// workspace lower bound. + pub(super) pinned_commits: IdSet, + /// Commits that START a segment. + pub(super) boundaries: IdSet, + /// The in-set entrypoint was NOT a boundary on its own and is forced to start a segment + /// (a checkout inside a stack). Its tip keeps the split's naming precedence: the checked-out + /// ref first, then plain disambiguation. + pub(super) entrypoint_forced_boundary: bool, + /// Which boundary's first-parent run each in-set commit belongs to. + pub(super) owner_of: IdMap, + /// The boundaries in materialization order: workspace first, then descending generation, id. + pub(super) tips: Vec, +} + +#[tracing::instrument(level = "trace", skip_all)] +pub(super) fn facts(b: &super::BuildInputs<'_>, meta: &T) -> Facts { + let &super::BuildInputs { + cg, + workspace_commit, + entrypoint, + target, + remote_tracking, + stack_branches, + project_meta, + options, + .. + } = b; + let n = cg.node_count(); + let in_set_at = local_commit_set(cg, workspace_commit, target); + let in_set_id = |id: gix::ObjectId| cg.index_of(id).is_some_and(|c| in_set_at[c]); + let children_at = in_set_children(cg, &in_set_at); + + let target_tip = project_meta + .target_ref + .as_ref() + .and_then(|tr| cg.commit_by_ref(tr.as_ref())); + // The checked-out commit may live outside the workspace entirely (an ad-hoc checkout + // in a repository that has one). Its history still meets the workspace somewhere โ€” at + // the first workspace commit reached walking down from it โ€” and that meeting commit + // must start a segment so the outside history has a clean edge to connect into. + // Chained into the rejoins below, just like the remote tips. + let entrypoint_outside = (!in_set_id(entrypoint)).then_some(entrypoint); + + // EXPLICIT tips (from_seeds) can point anywhere, and validation requires a tip + // id to be its segment's first commit โ€” so each one is a boundary. Workspace-discovered builds + // must not carve these: there they are ordinary interior commits. + let tip_ids: IdSet = if cg.explicit_seeds { + cg.seeds.iter().map(|t| t.id).collect() + } else { + IdSet::default() + }; + // Where each remote branch's history meets the workspace: the first workspace commit + // walking down from the remote tip. That meeting commit must start a segment so the + // remote's own commits have a clean edge to connect into. Only remotes whose local + // branch is in the workspace are shown, so only those may split โ€” except the target + // (`target_tip`), which is always shown; an outside checkout joins the same way. + let remote_rejoins: IdSet = remote_tracking + .iter() + .filter(|(local, _)| cg.commit_by_ref(local.as_ref()).is_some_and(in_set_id)) + .filter_map(|(_, r)| cg.commit_by_ref(r.as_ref())) + .chain(target_tip) + .chain(entrypoint_outside) + .filter_map(|tip| cg.first_on_spine(tip, |c| in_set_at[c])) + .collect(); + + // Is the checked-out workspace commit a real GitButler-managed merge, or a plain commit the ws ref + // merely sits on (co-located with a stack tip) or has advanced PAST (an "on-top" commit above the + // real merge)? Only a real merge is held in the workspace segment with its parents as stack tips; + // otherwise the workspace segment is empty and spliced in above, and the commit keeps its normal + // history and segmentation. + let ws_is_managed_merge = stack_branches.is_some() && cg.is_managed_ws_commit(workspace_commit); + + // The workspace commit's parents are stack tips โ€” always segment boundaries (so the workspace + // segment holds only the workspace commit, even when a parent is anonymous, e.g. an advanced tip). + // Only for a real managed merge; a plain checked-out tip, co-located stack tip, or advanced ref has + // no stack parents to split on. + let ws_parents: IdSet = if ws_is_managed_merge { + cg.parents(workspace_commit).collect() + } else { + IdSet::default() + }; + + let merge_first_parent_at = merge_first_parents(cg, &in_set_at); + + // Every commit a workspace stack branch points at starts a segment: even when the commit is + // name-ambiguous (several branches on it, so anonymous), the metadata branches float above it as + // empty segments, so the commit itself must begin its own (anonymous) segment. A branch that + // ADVANCED past the workspace anchors at its rejoin point instead โ€” the first in-workspace commit + // on its first-parent spine โ€” which must equally start a segment (the advanced branch is projected + // onto it via a sibling link). + // The persisted ad-hoc branch order counts as metadata branches the same way: its + // same-tip chains must anchor at a commit that starts its own segment, or an + // enclosing first-parent run would swallow the chain's commit. + let metadata_commits: IdSet = stack_branches + .unwrap_or(&[]) + .iter() + .flatten() + .chain(b.ad_hoc_chains.iter().flatten()) + .filter_map(|b| cg.commit_by_ref(b.as_ref())) + .filter_map(|tip| cg.first_on_spine(tip, |c| in_set_at[c])) + .collect(); + + // Stored/extra target positions must start their own segment: the projection's + // `TargetCommit::from_commit` ignores a stored target commit that sits mid-segment, losing the + // remembered base (and with it the workspace lower bound). Not restricted to the workspace set โ€” + // an older target position often sits inside the target REMOTE's ahead region. + let mut pinned_commits: IdSet = project_meta + .target_commit_id + .into_iter() + .chain(options.extra_target_commit_id) + .filter(|&c| cg.node(c).is_some()) + .collect(); + // EXPLICIT tips split ahead regions too (e.g. an integrated target riding inside a remote's + // ahead run must start its own segment there, like the walk's tip-seeded segments). + pinned_commits.extend(tip_ids.iter().copied()); + + // Segments are runs of commits that belong together; a commit STARTS a new one (a + // "boundary") whenever something interesting happens at it: the workspace tip or one + // of its stack tips, a spot marked above (remote meeting points, metadata branches, + // pinned targets), a merge, a point where histories part or meet (more than one + // child, or a child that doesn't continue this first-parent line), or โ€” checked + // last, the only test that touches refs and metadata โ€” a branch pointing here that + // can serve as the segment's name. + let is_boundary = |c: usize| -> bool { + let id = cg.id_at(c); + id == workspace_commit + || ws_parents.contains(&id) + || merge_first_parent_at[c] + || remote_rejoins.contains(&id) + || metadata_commits.contains(&id) + || pinned_commits.contains(&id) + || cg.connected_parent_count_at(c) > 1 + || { + let kids = children_at[c].as_slice(); + // Reached by a non-first-parent edge, or by more than one child. + kids.len() > 1 || kids.iter().any(|&k| cg.first_parent_at(k) != Some(c)) + } + // Last: the only check that touches refs and metadata. + || disambiguated_ref_at( + cg, + c, + remote_tracking, + meta, + Some(workspace_commit), + project_meta.target_ref.as_ref(), + ) + .is_some() + }; + let mut boundary_at = vec![false; n]; + for c in 0..n { + if in_set_at[c] && is_boundary(c) { + boundary_at[c] = true; + } + } + // A checkout inside a stack (from_tip): the entrypoint always starts its own + // segment โ€” planned here instead of splitting the enclosing segment after the build. + let entrypoint_forced_boundary = cg + .index_of(entrypoint) + .filter(|&e| in_set_at[e]) + .is_some_and(|e| !std::mem::replace(&mut boundary_at[e], true)); + + let (owner_of, tips_at) = runs_from_boundaries(cg, &boundary_at, &in_set_at, workspace_commit); + + Facts { + in_set: (0..n) + .filter(|&c| in_set_at[c]) + .map(|c| cg.id_at(c)) + .collect(), + ws_is_managed_merge, + pinned_commits, + boundaries: (0..n) + .filter(|&c| boundary_at[c]) + .map(|c| cg.id_at(c)) + .collect(), + entrypoint_forced_boundary, + owner_of, + tips: tips_at.iter().map(|&t| cg.id_at(t)).collect(), + } +} + +/// The commit set the LOCAL segments span: everything reachable from the workspace commit, plus the +/// target's own history WHEN the target has a local branch (it is `NotInRemote`) โ€” e.g. an +/// integrated `main` that sits outside the workspace. A remote-only target (ahead of its local, not +/// `NotInRemote`) is NOT added: it becomes a remote segment instead. +fn local_commit_set( + cg: &crate::CommitGraph, + workspace_commit: gix::ObjectId, + target: Option, +) -> Vec { + let mut in_set_at = vec![false; cg.node_count()]; + let mut stack: Vec = cg.index_of(workspace_commit).into_iter().collect(); + if let Some(t) = target + && cg + .node(t) + .is_some_and(|n| n.flags.contains(crate::CommitFlags::NotInRemote)) + { + stack.extend(cg.index_of(t)); + } + while let Some(c) = stack.pop() { + if !in_set_at[c] { + in_set_at[c] = true; + stack.extend(cg.connected_parents_at(c)); + } + } + in_set_at +} + +/// In-set children per commit, to detect branch points (a commit reached by >1 child). +fn in_set_children(cg: &crate::CommitGraph, in_set_at: &[bool]) -> Vec> { + let n = cg.node_count(); + let mut children_at: Vec> = vec![Vec::new(); n]; + for c in 0..n { + if !in_set_at[c] { + continue; + } + for p in cg.connected_parents_at(c) { + if in_set_at[p] { + children_at[p].push(c); + } + } + } + children_at +} + +/// A merge commit's segment holds only the merge, so its FIRST parent starts its own segment (the +/// second parent is already a boundary โ€” reached by a non-first-parent edge). +fn merge_first_parents(cg: &crate::CommitGraph, in_set_at: &[bool]) -> Vec { + let n = cg.node_count(); + let mut merge_first_parent_at = vec![false; n]; + for c in 0..n { + if in_set_at[c] + && cg.connected_parent_count_at(c) > 1 + && let Some(p) = cg.first_parent_at(c) + && in_set_at[p] + { + merge_first_parent_at[p] = true; + } + } + merge_first_parent_at +} + +/// Every boundary in the set starts a segment; each segment's commit run is the boundary plus its +/// first-parent tail up to (excluding) the next boundary. These runs partition the set, so assigning +/// each commit in a run to its boundary gives the owner directly โ€” no reverse walk (a run's oldest +/// commit, e.g. a root, has no first-parent path back up to its own boundary). +/// +/// Returns the owner map plus the segment tips in a stable order (workspace first, then by +/// descending generation, then id) so the numbering is deterministic even though it need not match +/// the walk's. +fn runs_from_boundaries( + cg: &crate::CommitGraph, + boundary_at: &[bool], + in_set_at: &[bool], + workspace_commit: gix::ObjectId, +) -> (IdMap, Vec) { + let mut owner_of: IdMap = IdMap::default(); + let mut tips_at: Vec = (0..cg.node_count()).filter(|&c| boundary_at[c]).collect(); + for &tip in &tips_at { + let tip_id = cg.id_at(tip); + let mut cursor = Some(tip); + while let Some(c) = cursor { + if c != tip && boundary_at[c] { + break; + } + owner_of.insert(cg.id_at(c), tip_id); + cursor = cg.first_parent_at(c).filter(|&p| in_set_at[p]); + } + } + tips_at.sort_by_key(|&t| { + ( + cg.id_at(t) != workspace_commit, + std::cmp::Reverse(cg.generation_at(t)), + cg.id_at(t), + ) + }); + (owner_of, tips_at) +} diff --git a/crates/but-graph/src/build/layout.rs b/crates/but-graph/src/build/layout.rs new file mode 100644 index 00000000000..db6be28d881 --- /dev/null +++ b/crates/but-graph/src/build/layout.rs @@ -0,0 +1,638 @@ +//! Derive the stored [`RefLayout`](crate::ref_layout::RefLayout) from the [`SegmentData`] โ€” +//! the build's ONE topologyโ†’truth derivation. The segments are linearized into an entry graph (per +//! segment: its name ref, then per commit its refs and the commit), each reference's +//! placement (`on`, the NAME below it, its entering edges) is read off that topology, and +//! the layout authors its groups from those parts. The positions computed here are +//! scaffolding, not output: nothing per-ref is stored, the layout's queries answer +//! positions back from the groups. + +use std::collections::HashMap; + +use anyhow::{Context as _, Result}; + +use super::IdMap; +use super::segment_data::{OrderedTargets, SegmentData}; +use crate::ref_layout::{MaterializedWsParents, Placement, RefFacts, RefLayout, RefPart}; + +/// One entry in a row's linearization. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Entry { + /// Index into `ref_table`. + Ref(usize), + /// Index into `commits`. + Commit(usize), + /// The placeholder for a row with neither name nor commits. + None, +} + +/// The scaffolding placements are read off: every segment linearized into entries โ€” +/// the segment's name ref first, then per commit its passive refs and the commit +/// itself โ€” wired into one graph by the parent edges. Positions fall out of walks over +/// it (down the first edges for `on` and below, up for the entering edges). Shared by +/// the phases of [`positions_from_segments`], and dropped once the layout is authored. +struct EntryGraph<'a> { + entries: Vec, + parents: Vec>, + /// `(name, reachable, names-a-segment: Some(segment-is-empty) | None for passive refs)` + ref_table: Vec<(gix::refs::FullName, bool, Option)>, + commits: Vec<(gix::ObjectId, &'a [gix::ObjectId])>, + /// Commit `c`'s entry handle, positionally โ€” the id-keyed map only serves parent-id lookups. + commit_entry_at: Vec, + commit_entry: IdMap, + reachable_commits: Vec, + head_refs: Vec, + segment_rows: Vec>, +} + +/// A ref's position over the entry topology, in entry handles. +struct DerivedPosition { + on: usize, + below: Option, + ambiguous: bool, + entering: Vec<(usize, usize)>, +} + +/// Derive the stored layout from the segments in `data`. The managed-workspace decision comes from +/// `cg`, which recognises managed commits by message. +#[tracing::instrument(level = "trace", skip_all)] +pub(super) fn derive_ref_layout(data: &SegmentData, cg: &crate::CommitGraph) -> Result { + let entrypoint_sidx = data + .entrypoint_sidx + .context("BUG: must always set the entrypoint")?; + let workspace_commit_id = data.entrypoint.filter(|&id| cg.is_managed_ws_commit(id)); + Ok(positions_from_segments( + cg, + &data.segments, + &data.extra_refs, + &data.parent_ordered_targets(cg), + entrypoint_sidx, + workspace_commit_id, + )) +} + +/// Compute the layout from the segments: per-segment entry rows (segment ref, then per commit its refs then the +/// commit), the parent fixup (a commit whose group-flattened parents disagree with its raw +/// parent list is rewired directly, bypassing groups โ€” the ws commit and partially-traversed +/// commits keep their wiring), position derivation, and the parent resolution. +/// +/// `workspace_commit_id` is the managed entrypoint commit, which takes its resolved CHAIN +/// materialized parents instead of its real ones. +fn positions_from_segments( + cg: &crate::CommitGraph, + segments: &[super::segment_data::Segment], + extra_refs: &HashMap>, + targets_of_segment: &OrderedTargets, + entrypoint_sidx: usize, + workspace_commit_id: Option, +) -> RefLayout { + let mut rows = build_entry_graph( + cg, + segments, + extra_refs, + targets_of_segment, + entrypoint_sidx, + ); + wire_segment_edges(&mut rows, targets_of_segment); + apply_parent_fixup(&mut rows, workspace_commit_id); + let ref_entries = collect_ref_entries(&rows.entries); + let mut positions = derive_positions(&rows, &ref_entries); + let ResolvedParents { + materialized_ws_parents, + full_incoming, + } = resolve_parents(&rows, workspace_commit_id, &mut positions); + // The oracle in id space: entry handles become commit ids, sorted like the rows' + // entering edges. This is the edge universe the EDITOR sees after ingest (the ws + // commit's materialized parents, unresolvable parents dropped) โ€” so a group classified + // `All` here is `All` there. + let incoming_by_id: HashMap> = full_incoming + .iter() + .filter_map(|(&handle, edges)| { + let Entry::Commit(c) = rows.entries[handle] else { + return None; + }; + let mut edges: Vec<_> = edges + .iter() + .filter_map(|&(child, parent_number)| { + let Entry::Commit(cc) = rows.entries[child] else { + return None; + }; + Some((rows.commits[cc].0, parent_number)) + }) + .collect(); + edges.sort_unstable(); + edges.dedup(); + Some((rows.commits[c].0, edges)) + }) + .collect(); + let incoming = |on: gix::ObjectId| -> Vec<(gix::ObjectId, usize)> { + incoming_by_id.get(&on).cloned().unwrap_or_default() + }; + assemble_layout( + rows, + &ref_entries, + &positions, + materialized_ws_parents, + &incoming, + ) +} + +/// Entry build: one row of entries per segment, in table order. +fn build_entry_graph<'a>( + cg: &'a crate::CommitGraph, + segments: &[super::segment_data::Segment], + extra_refs: &HashMap>, + targets_of_segment: &OrderedTargets, + entrypoint_sidx: usize, +) -> EntryGraph<'a> { + // A ref that names a segment (or is a named segment's remote-tracking counterpart) + // stays out of the commits' own ref lists, as does every remote-category ref. + let segment_names: std::collections::HashSet<&gix::refs::FullName> = segments + .iter() + .flat_map(|r| { + r.name + .as_ref() + .into_iter() + .chain(r.remote_tracking_ref_name.as_ref()) + }) + .collect(); + let commit_ref_names = |h: usize| -> Vec<&gix::refs::FullName> { + let mut names: Vec<&gix::refs::FullName> = cg + .node_at(h) + .refs + .iter() + .map(|ri| &ri.ref_name) + .filter(|name| { + !segment_names.contains(name) + && name.category() != Some(gix::reference::Category::RemoteBranch) + }) + .collect(); + if let Some(extra) = extra_refs.get(&h) { + // Same filter as above: a displaced name a later pass (the ad-hoc same-tip + // rebuild) turned into a segment name must not ALSO ride as a commit ref. + for name in extra { + if !segment_names.contains(name) && !names.contains(&name) { + names.push(name); + } + } + names.sort(); + } + names + }; + // Reach: every segment the entrypoint's segment descends into through the parent-ordered edges. + let mut reachable_sidx_at = vec![false; segments.len()]; + let mut queue = vec![entrypoint_sidx]; + while let Some(ri) = queue.pop() { + if !std::mem::replace(&mut reachable_sidx_at[ri], true) { + queue.extend(targets_of_segment.of(ri)); + } + } + let head_name = segments[entrypoint_sidx].ref_name().map(|n| n.to_owned()); + + let mut entries: Vec = Vec::new(); + let mut parents: Vec> = Vec::new(); + let mut ref_table: Vec<(gix::refs::FullName, bool, Option)> = Vec::new(); + let mut commits: Vec<(gix::ObjectId, &[gix::ObjectId])> = Vec::new(); + let mut commit_entry_at: Vec = Vec::new(); + let mut commit_entry = IdMap::::default(); + let mut reachable_commits = Vec::new(); + let mut head_refs = Vec::new(); + let mut segment_rows = Vec::new(); + + for (ri, data) in segments.iter().enumerate() { + let reachable = reachable_sidx_at[ri]; + let mut row: Vec = vec![]; + let push = |entries: &mut Vec, parents: &mut Vec>, entry| { + entries.push(entry); + parents.push(vec![]); + entries.len() - 1 + }; + + if let Some(reference) = data.ref_name() { + if head_name.as_ref().map(|n| n.as_ref()) == Some(reference) { + head_refs.push(reference.to_owned()); + } + ref_table.push(( + reference.to_owned(), + reachable, + Some(data.commits.is_empty()), + )); + let n = push(&mut entries, &mut parents, Entry::Ref(ref_table.len() - 1)); + row.push(n); + } + for &h in &data.commits { + let commit = cg.node_at(h); + if reachable { + reachable_commits.push(commit.id); + } + for name in commit_ref_names(h) { + ref_table.push((name.clone(), reachable, None)); + let n = push(&mut entries, &mut parents, Entry::Ref(ref_table.len() - 1)); + if let Some(&previous) = row.last() { + parents[previous].push(n); + } + row.push(n); + } + commits.push((commit.id, commit.parent_ids.as_slice())); + let n = push(&mut entries, &mut parents, Entry::Commit(commits.len() - 1)); + commit_entry_at.push(n); + commit_entry.insert(commit.id, n); + if let Some(&previous) = row.last() { + parents[previous].push(n); + } + row.push(n); + } + if row.is_empty() { + row.push(push(&mut entries, &mut parents, Entry::None)); + } + segment_rows.push(row); + } + + EntryGraph { + entries, + parents, + ref_table, + commits, + commit_entry_at, + commit_entry, + reachable_commits, + head_refs, + segment_rows, + } +} + +/// The parent-ordered edges land on each row's LAST entry, pointing at the target row's FIRST entry. +fn wire_segment_edges(rows: &mut EntryGraph<'_>, targets_of_segment: &OrderedTargets) { + let EntryGraph { + parents, + segment_rows, + .. + } = rows; + let first_entry_of_row: Vec = segment_rows + .iter() + .map(|row| *row.first().expect("every row has an entry")) + .collect(); + for (ri, row) in segment_rows.iter().enumerate() { + let source = *row.last().expect("every row has an entry"); + for &target in targets_of_segment.of(ri) { + parents[source].push(first_entry_of_row[target]); + } + } +} + +/// The fixup: flatten a commit's group parents in parent number order; on disagreement with the +/// RAW parent list, rewire directly to present commits (groups lose their edges). The ws +/// commit and partially-traversed commits keep their segment wiring. The flattening +/// compares in-place against the raw list โ€” no per-commit id collection. +fn apply_parent_fixup(rows: &mut EntryGraph<'_>, workspace_commit_id: Option) { + let EntryGraph { + entries, + parents, + commits, + commit_entry_at, + commit_entry, + .. + } = rows; + let mut stack: Vec = Vec::new(); + let flatten_matches = |entries: &[Entry], + parents: &[Vec], + commits: &[(gix::ObjectId, &[gix::ObjectId])], + start: usize, + want: &[gix::ObjectId], + stack: &mut Vec| + -> bool { + stack.clear(); + stack.extend(parents[start].iter().rev().copied()); + let mut matched = 0; + while let Some(n) = stack.pop() { + match entries[n] { + Entry::Commit(c) => { + if want.get(matched) != Some(&commits[c].0) { + return false; + } + matched += 1; + } + Entry::Ref(_) | Entry::None => { + stack.extend(parents[n].iter().rev().copied()); + } + } + } + matched == want.len() + }; + for (ci, &(id, raw_parents)) in commits.iter().enumerate() { + if Some(id) == workspace_commit_id { + continue; + } + let preserved = + !raw_parents.is_empty() && raw_parents.iter().any(|p| !commit_entry.contains_key(p)); + if preserved { + continue; + } + let n = commit_entry_at[ci]; + if flatten_matches(entries, parents, commits, n, raw_parents, &mut stack) { + continue; + } + parents[n] = raw_parents + .iter() + .filter_map(|p| commit_entry.get(p).copied()) + .collect(); + } +} + +/// Every `Entry::Ref`, as `(entry handle, ref-table index)`. +fn collect_ref_entries(entries: &[Entry]) -> Vec<(usize, usize)> { + entries + .iter() + .enumerate() + .filter_map(|(n, entry)| match entry { + Entry::Ref(r) => Some((n, *r)), + _ => None, + }) + .collect() +} + +/// Follow first-edges from `start` down to the first commit entry. Returns that commit +/// (`None` when the path ends unborn) and the first ref entry passed on the way โ€” a +/// ref's `below` neighbour. Fuel: a simple path cannot exceed the entry count โ€” running +/// out means a cycle, which the entry graph must never contain. +fn descend_to_commit( + entries: &[Entry], + parents: &[Vec], + start: usize, +) -> (Option, Option) { + let mut cursor = start; + let mut below = None; + for fuel in (0..=entries.len()).rev() { + debug_assert!( + fuel != 0, + "BUG: cycle in the entry graph (first-edge descent)" + ); + if fuel == 0 { + break; + } + match entries[cursor] { + Entry::Commit(_) => return (Some(cursor), below), + Entry::Ref(_) if cursor != start && below.is_none() => below = Some(cursor), + _ => {} + } + let Some(&next) = parents[cursor].first() else { + break; + }; + cursor = next; + } + (None, below) +} + +/// Positions from the post-fixup topology, BEFORE parents are resolved past the ref +/// entries: descend first-edges for `on` and +/// below, ascend for the entering edges and the convergence signal. The reverse adjacency +/// is CSR-shaped (offsets into one flat run) โ€” a Vec per entry costs an allocation per commit. +fn derive_positions( + rows: &EntryGraph<'_>, + ref_entries: &[(usize, usize)], +) -> HashMap { + let EntryGraph { + entries, parents, .. + } = rows; + let mut incoming_start = vec![0usize; entries.len() + 1]; + for adjacency in parents { + for &parent in adjacency { + incoming_start[parent + 1] += 1; + } + } + for i in 0..entries.len() { + incoming_start[i + 1] += incoming_start[i]; + } + let mut incoming_flat: Vec<(usize, usize)> = + vec![(0, 0); *incoming_start.last().expect("n+1 offsets")]; + { + let mut fill = incoming_start.clone(); + for (child, adjacency) in parents.iter().enumerate() { + for (parent_number, &parent) in adjacency.iter().enumerate() { + incoming_flat[fill[parent]] = (child, parent_number); + fill[parent] += 1; + } + } + } + let incoming = |n: usize| &incoming_flat[incoming_start[n]..incoming_start[n + 1]]; + let is_commit = |n: usize| matches!(entries[n], Entry::Commit(_)); + let mut positions = HashMap::::new(); + for &(ref_entry, _) in ref_entries { + let (on, below) = descend_to_commit(entries, parents, ref_entry); + let Some(on) = on else { + continue; // unborn: no stored position + }; + let mut cursor = ref_entry; + let mut entering_edges = Vec::new(); + let mut ambiguous = false; + for fuel in (0..=entries.len()).rev() { + debug_assert!(fuel != 0, "BUG: cycle in the entry graph (entering ascent)"); + if fuel == 0 { + break; + } + let entering = incoming(cursor); + let picks: Vec<_> = entering + .iter() + .copied() + .filter(|&(child, _)| is_commit(child)) + .collect(); + if !picks.is_empty() { + ambiguous = entering.len() > 1; + entering_edges = picks; + break; + } + let mut others = entering.iter().filter(|&&(child, _)| !is_commit(child)); + match (others.next(), others.next()) { + (Some(&(child, _)), None) => cursor = child, + _ => break, + } + } + positions.insert( + ref_entry, + DerivedPosition { + on, + below, + ambiguous, + entering: entering_edges, + }, + ); + } + positions +} + +/// What resolving every commit's parents through the entry graph yields: the ws +/// commit's MATERIALIZED parents (one per ref chain, so empty chains over one base +/// yield duplicate entries the real commit does not have), and the full incoming edge +/// set per commit entry โ€” the `GroupCarry::All` oracle. An unborn parent drops out of +/// the list, and the captured entering edges are renumbered to the FINAL parent +/// parent numbers โ€” only here does an entry-graph position become a real parent number. +struct ResolvedParents { + materialized_ws_parents: Option, + full_incoming: HashMap>, +} + +fn resolve_parents( + rows: &EntryGraph<'_>, + workspace_commit_id: Option, + positions: &mut HashMap, +) -> ResolvedParents { + let EntryGraph { + entries, + parents, + commits, + commit_entry_at, + .. + } = rows; + let resolve = |start: usize| -> Option { descend_to_commit(entries, parents, start).0 }; + // Per edge source: its vacated positions, ascending (filled in order below). + let mut dropped: HashMap> = HashMap::new(); + let mut materialized_ws_parents: Option = None; + // The full incoming edge set per commit entry, in the SAME (post-resolution) edge + // universe the entering edges live in โ€” the `GroupCarry::All` oracle: a group carries + // All exactly when its entering equals its commit's full set. + let mut full_incoming: HashMap> = HashMap::new(); + for (ci, &(id, _)) in commits.iter().enumerate() { + let n = commit_entry_at[ci]; + // Only the ws commit keeps its resolved ids โ€” everyone else just detects + // vacated positions. + let is_ws = Some(id) == workspace_commit_id; + let mut resolved = Vec::new(); + let mut vacated_below = 0usize; + for (position, &parent) in parents[n].iter().enumerate() { + match resolve(parent) { + Some(handle) => { + // The entry-graph position minus its vacancies IS the parent number. + let parent_slot = position - vacated_below; + full_incoming + .entry(handle) + .or_default() + .push((n, parent_slot)); + if is_ws { + let Entry::Commit(c) = entries[handle] else { + unreachable!("resolve returns commits"); + }; + resolved.push(commits[c].0); + } + } + None => { + dropped.entry(n).or_default().push(position); + vacated_below += 1; + } + } + } + if is_ws { + materialized_ws_parents = Some(MaterializedWsParents { + commit: id, + parents: resolved, + }); + } + } + for position in positions.values_mut() { + for (edge_source, parent_number) in position.entering.iter_mut() { + if let Some(vacated) = dropped.get(edge_source) { + *parent_number -= vacated.partition_point(|v| v < parent_number); + } + } + } + ResolvedParents { + materialized_ws_parents, + full_incoming, + } +} + +/// The stored shape: ref-table order, entry handles translated to ref ordinals and commit +/// ids, entering edges sorted. +fn assemble_layout( + rows: EntryGraph<'_>, + ref_entries: &[(usize, usize)], + positions: &HashMap, + materialized_ws_parents: Option, + incoming: &dyn Fn(gix::ObjectId) -> Vec<(gix::ObjectId, usize)>, +) -> RefLayout { + let EntryGraph { + entries, + ref_table, + commits, + mut reachable_commits, + head_refs, + .. + } = rows; + let mut entry_of_ref = vec![0usize; ref_table.len()]; + for &(n, r) in ref_entries { + entry_of_ref[r] = n; + } + // One (facts, placement) pair per reference, table order โ€” the parts the layout + // authors its groups from. The placement's below is the NAME of the ref underneath: + // groups reference by name, never by ordinal. + let parts: Vec<(gix::refs::FullName, RefFacts, Option)> = ref_table + .into_iter() + .enumerate() + .map(|(r, (name, reachable, naming))| { + let placement = positions.get(&entry_of_ref[r]).map(|position| { + let Entry::Commit(c) = entries[position.on] else { + unreachable!("positions sit on commits"); + }; + let below = position.below.map(|b| { + let Entry::Ref(br) = entries[b] else { + unreachable!("below entries are refs"); + }; + br + }); + let mut entering: Vec<(gix::ObjectId, usize)> = position + .entering + .iter() + .map(|&(child, parent_number)| { + let Entry::Commit(cc) = entries[child] else { + unreachable!("entering edges come from commits"); + }; + (commits[cc].0, parent_number) + }) + .collect(); + entering.sort_unstable(); + EntryPlacement { + on: commits[c].0, + below, + entering, + } + }); + let facts = RefFacts { + reachable, + names_segment: naming.is_some(), + names_empty_segment: naming.unwrap_or_default(), + ambiguous: positions + .get(&entry_of_ref[r]) + .is_some_and(|position| position.ambiguous), + }; + (name, facts, placement) + }) + .collect(); + // below as a name: resolvable only now that every ref's table ordinal is known. + let names: Vec = parts.iter().map(|(name, ..)| name.clone()).collect(); + let parts = parts + .into_iter() + .map(|(name, facts, placement)| RefPart { + name, + facts, + placement: placement.map(|p| Placement { + on: p.on, + below: p.below.map(|b| names[b].clone()), + entering: p.entering, + }), + }) + .collect(); + + reachable_commits.sort_unstable(); + let mut layout = RefLayout::from_parts(parts, incoming); + layout.materialized_ws_parents = materialized_ws_parents; + layout.head_refs = head_refs; + layout.reachable_commits = reachable_commits; + // empty_chain_anchors is stamped by the caller from the plan's chain knowledge. + layout +} + +/// A reference's resolved placement, in id/ordinal space, before below-ordinals become +/// names. +struct EntryPlacement { + on: gix::ObjectId, + below: Option, + entering: Vec<(gix::ObjectId, usize)>, +} diff --git a/crates/but-graph/src/build/materialize.rs b/crates/but-graph/src/build/materialize.rs new file mode 100644 index 00000000000..953e654c641 --- /dev/null +++ b/crates/but-graph/src/build/materialize.rs @@ -0,0 +1,323 @@ +//! Phase 3 of gather-then-build: author the segment data from the commit-graph and the +//! chain plan; the ref-position derivation then reads the record into the stored layout. + +use std::collections::{HashMap, HashSet}; + +use gix::reference::Category; + +use super::facts::Facts; +use super::plan::ChainPlan; +use super::remotes::remote_name_in_play; +use super::{IdMap, IdSet, disambiguated_ref, is_plain_local_branch}; +use crate::CommitGraph; + +/// Materialize the [`SegmentData`](super::segment_data::SegmentData) โ€” the record โ€” from +/// `b.cg`. +/// +/// `b` is the build context every phase reads. +/// +/// This is "gather-then-build": everything is decided as data BEFORE any segment exists โ€” +/// the caller authors `f`/`plan`/`layout` via `gather_and_plan`; this function adds the +/// repository/metadata-flavored decisions (the empty-workspace splice name, the advanced-outside +/// branches, the claimed remote names), then [`build`](super::segment_data::build) +/// materializes the record. The record never becomes graph storage: after any ad-hoc +/// rewrites, `derive_ref_layout` reads it into the stored ref layout and `enrich` +/// mines it for commit details โ€” then it is dropped. +#[tracing::instrument( + name = "graph_from_commit_graph", + level = "trace", + skip_all, + fields(commits = b.cg.node_count()) +)] +pub(crate) fn graph_from_commit_graph( + b: &super::BuildInputs<'_>, + meta: &T, + f: Facts, + plan: &ChainPlan, + layout: &super::plan::LayoutPlan, +) -> super::segment_data::SegmentData { + let &super::BuildInputs { + cg, + workspace_commit, + entrypoint, + entrypoint_ref, + remote_tracking, + symbolic_remotes, + stack_branches, + project_meta, + options, + .. + } = b; + let Facts { + in_set, + ws_is_managed_merge, + pinned_commits, + boundaries, + entrypoint_forced_boundary: _, + owner_of, + tips, + } = f; + + // RefChain-structure decisions: the empty-workspace splice's name and the advanced-outside + // branches. + let managed = stack_branches.is_some(); + let ws_empty_ref = (managed && !ws_is_managed_merge) + .then(|| empty_workspace_ref(cg, workspace_commit)) + .flatten(); + let advanced_outside = if managed { + let mut named: HashSet = tips + .iter() + .filter_map(|&tip| plan.tip_name(tip).map(|nt| nt.name)) + .collect(); + named.extend(plan.floats.iter().map(|fl| fl.name.clone())); + named.extend(ws_empty_ref.clone()); + advanced_outside_decisions( + cg, + &in_set, + &owner_of, + stack_branches, + remote_tracking, + meta, + project_meta.target_ref.as_ref(), + &pinned_commits, + named, + ) + } else { + Vec::new() + }; + + let claimed_remote_names = claim_remote_names( + cg, + plan, + &in_set, + remote_tracking, + stack_branches, + symbolic_remotes, + ); + // The entrypoint is a planned boundary in every region too: a checkout inside a remote's + // ahead run starts its own segment at creation, never split out after the fact. + let region_pinned = { + let mut p = pinned_commits.clone(); + p.insert(entrypoint); + p + }; + super::segment_data::build(super::segment_data::GraphInputs { + cg, + tips: &tips, + in_set: &in_set, + boundaries: &boundaries, + owner_of: &owner_of, + plan, + layout, + ad_hoc_chain_start: layout.chains.len() - b.ad_hoc_chains.len(), + workspace_commit, + ws_empty_ref: ws_empty_ref.as_ref(), + advanced_outside: &advanced_outside, + remote_tracking, + symbolic_remotes, + stack_branches, + region_pinned: ®ion_pinned, + claimed_remote_names: &claimed_remote_names, + entrypoint, + entrypoint_ref, + target_ref: project_meta.target_ref.as_ref(), + extra_target: options.extra_target_commit_id, + }) +} + +/// Remote refs some creator will consume as a segment name: the region builder cuts its run +/// at interior remote refs only when unclaimed. Plan-modeled names (`remote_used` covers the +/// walk seeds) plus the ahead-case remotes of EVERY boundary-tip local (`add_remote_segments` +/// regions all of them, mirroring its gates) plus explicit-tip remote names. +fn claim_remote_names( + cg: &CommitGraph, + plan: &ChainPlan, + in_set: &IdSet, + remote_tracking: &HashMap, + stack_branches: Option<&[Vec]>, + symbolic_remotes: &[String], +) -> HashSet { + let mut claimed_remote_names: HashSet = plan.remote_used.clone(); + claimed_remote_names.extend(plan.base_name_of.values().filter_map(|name| { + let rt = remote_tracking.get(name)?; + let rt_tip = cg.commit_by_ref(rt.as_ref())?; + (!in_set.contains(&rt_tip) + && remote_name_in_play(rt, symbolic_remotes) + && !super::is_stack_branch(stack_branches, rt)) + .then(|| rt.clone()) + })); + if cg.explicit_seeds { + claimed_remote_names.extend(cg.seeds.iter().filter_map(|t| { + t.ref_name + .clone() + .filter(|r| r.as_ref().category() == Some(Category::RemoteBranch)) + })); + } + claimed_remote_names +} + +/// The per-tip minting decision: the tip's commit run (a float's displaced build-time name +/// riding on the tip commit as a passive ref) and its planned name โ€” `None` for floated/anonymized +/// tips, which start ANONYMOUS. +pub(super) fn tip_run_and_name( + cg: &CommitGraph, + tip: gix::ObjectId, + in_set: &IdSet, + boundaries: &IdSet, + plan: &ChainPlan, + visit: impl FnMut(usize), +) -> TipRun { + let is_boundary = |c: gix::ObjectId| boundaries.contains(&c); + let float = plan.floats.iter().find(|fl| fl.tip == tip); + let commits = commit_run(cg, tip, in_set, &is_boundary, visit); + let named = plan.tip_name(tip); + let displaced = float + .and_then(|fl| fl.displaced_ref_name.as_ref()) + .filter(|displaced| { + commits.first().is_some_and(|&c0| { + !cg.node_at(c0) + .refs + .iter() + .any(|r| r.ref_name == **displaced) + }) + }) + .cloned(); + TipRun { + commits, + named, + displaced, + } +} + +/// One tip's minting decision, in arena handles. +pub(super) struct TipRun { + pub commits: Vec, + pub named: Option, + /// A float's displaced build-time name, riding on the tip commit as a passive ref. + pub displaced: Option, +} + +/// The first-parent commit run owned by `tip`: `tip` and each first-parent descendant-in-history until +/// the next boundary (exclusive) or the set edge. `visit` sees each member's handle. +pub(super) fn commit_run( + cg: &CommitGraph, + tip: gix::ObjectId, + in_set: &IdSet, + is_boundary: &impl Fn(gix::ObjectId) -> bool, + mut visit: impl FnMut(usize), +) -> Vec { + // Handle space: one `by_id` lookup for the tip, plain vec reads after. + let mut out = Vec::new(); + let mut cursor = cg.index_of(tip); + while let Some(c) = cursor { + let id = cg.id_at(c); + if !in_set.contains(&id) { + break; + } + if id != tip && is_boundary(id) { + break; + } + visit(c); + out.push(c); + cursor = cg.first_parent_at(c); + } + out +} + +/// The name an empty-workspace splice carries. The traversal may have +/// dropped the special workspace ref from the commit's refs when a stack branch on the same +/// commit named its raw segment โ€” the caller established the ref points here, so fall back to +/// the well-known name rather than silently skipping the workspace segment. +pub(super) fn empty_workspace_ref( + cg: &CommitGraph, + workspace_commit: gix::ObjectId, +) -> Option { + cg.refs_at(workspace_commit) + .into_iter() + .find(|r| but_core::is_workspace_ref_name(r.as_ref())) + .or_else(|| but_core::WORKSPACE_REF_NAME.try_into().ok()) +} + +/// A metadata stack branch pointing at a commit OUTSIDE the workspace that has advanced past it, +/// decided as data: its outside run, the in-workspace commit it rejoins, +/// and its disambiguated name. +pub(super) struct AdvancedOutside { + pub(super) tip: gix::ObjectId, + pub(super) name: Option, + /// The branch's outside run, in arena handles. + pub(super) commits: Vec, + pub(super) rejoin: gix::ObjectId, +} + +/// The advanced-outside decisions, in metadata-stack order. `named` carries every ref name a +/// segment holds when the pass starts (mint names, float placeholders, the empty-workspace +/// splice) โ€” a branch that already names a segment does not advance. +#[expect(clippy::too_many_arguments)] +pub(super) fn advanced_outside_decisions( + cg: &CommitGraph, + in_set: &IdSet, + owner_of: &IdMap, + stack_branches: Option<&[Vec]>, + remote_tracking: &HashMap, + meta: &T, + target_ref: Option<&gix::refs::FullName>, + pinned_commits: &IdSet, + mut named: HashSet, +) -> Vec { + let mut decisions: Vec = Vec::new(); + let mut outside_owned = IdSet::default(); + for b in stack_branches.into_iter().flatten().flatten() { + // Only LOCAL branches advance past a workspace; metadata can also list remote refs as stack + // branches, and those are handled by the remote passes. + if !is_plain_local_branch(b) || named.contains(b) { + continue; + } + let Some(tip) = cg.commit_by_ref(b.as_ref()) else { + continue; + }; + if in_set.contains(&tip) { + continue; + } + // A PINNED commit (a stored/extra target position) must start its own segment via the + // extra-target region โ€” the projection derives the remembered base from it. When chains + // run before the remote passes this pass would otherwise swallow it into the branch's + // outside run first. + if pinned_commits.contains(&tip) { + continue; + } + // The branch's outside commits, down to where it rejoins the workspace. + let mut commits: Vec = Vec::new(); + let mut cursor = Some(tip); + let mut rejoin = None; + while let Some(id) = cursor { + if in_set.contains(&id) { + rejoin = Some(id); + break; + } + if let Some(h) = cg.index_of(id) { + commits.push(h); + } + cursor = cg.first_parent(id); + } + let (Some(rejoin), false) = (rejoin, commits.is_empty()) else { + continue; + }; + // Several stack branches can share the outside tip (e.g. an applied-branch preview where + // `E` and `D` rest on the same not-yet-merged commit) โ€” the run is materialized ONCE. + if outside_owned.contains(&tip) || !owner_of.contains_key(&rejoin) { + continue; + } + // Named like any tip: ambiguous refs keep the segment anonymous (the walk's floating + // `โ–บD, โ–บE` run), a unique branch names it (the advanced `B` above its own chain). + let name = disambiguated_ref(cg, tip, remote_tracking, meta, None, target_ref); + outside_owned.extend(commits.iter().map(|&h| cg.id_at(h))); + named.extend(name.clone()); + decisions.push(AdvancedOutside { + tip, + name, + commits, + rejoin, + }); + } + decisions +} diff --git a/crates/but-graph/src/build/mod.rs b/crates/but-graph/src/build/mod.rs new file mode 100644 index 00000000000..cf84fc48509 --- /dev/null +++ b/crates/but-graph/src/build/mod.rs @@ -0,0 +1,815 @@ +//! The graph builders: the workspace's ref layout and build context are authored here onto a +//! [`CommitGraph`] flattened out of the raw traversal ([`CommitGraph::from_walk`]), so +//! everything downstream sees one substrate shape regardless of how the build was entered +//! (managed workspace, non-managed checkout, explicit seeds, overlays). The build's segments +//! are internal scaffolding โ€” they author the layout and never leave the builder. +//! +//! The build is phased (gather-then-build), one file per phase: [`facts`] (pure boundary/shape +//! reads) โ†’ [`plan`] (the chain plan, pure data) โ†’ [`materialize`] (repository-flavored +//! decisions feeding [`segment_data`]) โ†’ [`layout`] (the stored ref layout read off the +//! record), with [`remotes`] holding the remote-tracking claims. Entry points and shared +//! helpers live here. +//! +//! THE STRUCTURAL INVARIANT: no pass rewrites another pass's structure. Every mutation is +//! either additive construction in fresh territory (minting, remote regions) or a declared +//! materializer applying a plan decision (chain splices, anonymous bases, the entrypoint +//! placement). The segment record itself is temporary: decisions go in, the stored layout is +//! read off it, then it is dropped โ€” it never becomes graph storage. + +use std::collections::{BTreeMap, HashMap, HashSet}; + +use but_core::RefMetadata; + +/// `ObjectId`-keyed map/set on the prehashed [`gix::hashtable`] โ€” ids are touched per commit in +/// the hot build loops, where SipHash on 20-byte keys dominates profiles. +type IdMap = gix::hashtable::HashMap; +type IdSet = gix::hashtable::HashSet; +use gix::reference::Category; + +use crate::{ + CommitGraph, + walk::overlay::{OverlayMetadata, OverlayRepo}, + workspace::GraphContext, +}; + +use materialize::graph_from_commit_graph; +use remotes::remote_tracking_from_repository; + +mod ad_hoc; +mod facts; +mod layout; +mod materialize; +mod plan; +mod remote_segments; +mod remotes; +mod segment_data; + +/// Project a PROVIDED `cg` โ€” the write-through seam: an editor-mutated commit graph projects +/// like a fresh walk, with enrichment (refs, target, metadata) read from `repo`/`meta` amended +/// by `overlay` (default overlay = current state, a materialized rebase; a rebase's overlay +/// serves pending ref edits and entrypoint from memory, a preview). Dispatches like +/// `Graph::from_head`: managed when the workspace ref resolves to a +/// commit in `cg` (falling through to non-managed when the entrypoint lands outside the +/// workspace), non-managed otherwise. `None` when the entrypoint is unborn or unresolvable. +pub(crate) fn workspace_from_commit_graph( + mut cg: CommitGraph, + repo: &gix::Repository, + meta: &T, + project_meta: but_core::ref_metadata::ProjectMeta, + options: crate::walk::Options, + overlay: crate::walk::Overlay, +) -> anyhow::Result> { + let (overlay_repo, overlay_meta, overlay_entrypoint) = overlay.into_parts(repo, meta); + let ws_ref: gix::refs::FullName = but_core::WORKSPACE_REF_NAME.try_into()?; + let ws_tip_on_disk = resolve_ref_tip(&overlay_repo, ws_ref.as_ref())?; + let ws_commit = ws_tip_on_disk.filter(|c| cg.node(*c).is_some()); + // The walk seeds `InWorkspace` and the project target only for a workspace with METADATA + // whose ref resolves โ€” a bare `gitbutler/workspace` ref without it walks (and projects) + // as a plain branch. + let ws_meta = overlay_meta.workspace_opt(ws_ref.as_ref())?; + let ws_has_meta = ws_meta.is_some(); + let ws_exists = ws_has_meta && ws_tip_on_disk.is_some(); + // A target tip the editor dropped or rewrote away is still external context on disk โ€” + // the walk seeds it as an integrated tip whenever the commit exists, so re-represent it. + // The stored target commit and the target REF tip only count when a workspace exists (the + // walk pushes them per discovered workspace); the extra target is seeded unconditionally. + let target_ref_tip = match project_meta.target_ref.as_ref().filter(|_| ws_exists) { + Some(tr) => resolve_ref_tip(&overlay_repo, tr.as_ref())?, + None => None, + }; + let target_tips = || { + project_meta + .target_commit_id + .filter(|_| ws_exists) + .into_iter() + .chain(options.extra_target_commit_id) + .chain(target_ref_tip) + }; + for tip in target_tips() { + ensure_tip_region(&mut cg, &overlay_repo, tip, crate::CommitFlags::Integrated)?; + } + ensure_remote_regions(&mut cg, repo, &overlay_repo, &project_meta)?; + let (head_tip, head_ref) = match overlay_entrypoint { + Some((id, ref_name)) => (Some(id), ref_name), + None => { + let head = repo.head()?; + ( + head.id().map(|id| id.detach()), + head.referent_name().map(|n| n.to_owned()), + ) + } + }; + // HEAD is external context too: the editor's graph need not contain the checked-out commit + // (e.g. an edit-mode WIP commit) โ€” the walk always traverses the entrypoint's region. + if let Some(tip) = head_tip { + ensure_tip_region(&mut cg, &overlay_repo, tip, crate::CommitFlags::empty())?; + } + let head_tip = head_tip.filter(|c| cg.node(*c).is_some()); + // Reconcile edges LAST: the region steps above revive tombstones, and a revival flips + // effective parents that must then be re-validated against the odb. + cg.complete_parents_from_odb(&overlay_repo)?; + // `Integrated` = target-reachability. `InWorkspace` follows the walk's rule: only the + // workspace tip seeds it (`None` clears it everywhere). + cg.set_flag_on_ancestors(crate::CommitFlags::Integrated, target_tips()); + cg.set_flag_on_ancestors( + crate::CommitFlags::InWorkspace, + ws_commit.filter(|_| ws_has_meta), + ); + // `NotInRemote` mirrors the walk's seeding: only tips the walk QUEUES seed it โ€” HEAD, + // and for a discovered workspace its tip, the target's local tracking branch, and the + // workspace stack branch refs. A local branch that merely points into remote-reachable + // history is NOT a seed, and an editor drop must lose the flag entirely (a stale flag + // would hide the remote region from projection). + let mut not_in_remote_tips: Vec = head_tip.into_iter().collect(); + if ws_exists { + not_in_remote_tips.extend(ws_tip_on_disk); + if let Some((_, _, Some((_, local_tip)))) = + crate::walk::workspace_target_tip(&overlay_repo, project_meta.target_ref.as_ref())? + { + not_in_remote_tips.push(local_tip); + } + for branch in ws_meta + .iter() + .flat_map(|ws| ws.stacks.iter()) + .filter(|s| s.is_in_workspace()) + .flat_map(|s| s.branches.iter()) + { + not_in_remote_tips.extend(crate::walk::utils::try_refname_to_id( + &overlay_repo, + branch.ref_name.as_ref(), + )?); + } + } + cg.set_flag_on_ancestors(crate::CommitFlags::NotInRemote, not_in_remote_tips); + cg.recompute_generations(); + // Editor tombstones are seam-internal: reconciliation left no live edge into them, so + // compaction yields the same graph a fresh walk would โ€” which matters now that the + // projection's carried graph becomes THE workspace graph downstream consumers reuse. + cg.compact(); + let ref_prefixes = || { + ["refs/heads/", "refs/remotes/"] + .into_iter() + .chain(options.collect_tags.then_some("refs/tags/")) + }; + let head_on_ws = head_ref + .as_ref() + .is_some_and(|r| but_core::is_workspace_ref_name(r.as_ref())); + if let Some(ws_commit) = ws_commit { + // The dispatch's entrypoint rule: HEAD on the workspace ref is the plain case, + // any other checkout is an entrypoint split within the managed workspace. + let (entrypoint, entrypoint_ref) = match head_tip { + Some(tip) if !head_on_ws => (tip, head_ref.clone()), + _ => (ws_commit, None), + }; + let main_head_ref = if entrypoint == ws_commit { + entrypoint_ref.clone().or_else(|| Some(ws_ref.clone())) + } else { + entrypoint_ref.clone() + }; + let mut refs_by_id = + overlay_repo.collect_ref_mapping_by_prefix(ref_prefixes(), &[ws_ref.as_ref()])?; + let worktree_by_branch = + overlay_repo.worktree_branches(main_head_ref.as_ref().map(|r| r.as_ref()))?; + cg.refresh_refs(&mut refs_by_id, &worktree_by_branch); + let (managed_cg, ctx, entrypoint_reached) = assemble_managed( + cg, + repo, + &overlay_repo, + &overlay_meta, + &ws_ref, + ws_commit, + entrypoint, + entrypoint_ref, + main_head_ref.as_ref(), + project_meta.clone(), + options.clone(), + )?; + // Entrypoint never made it into a segment โ€” fall through to the non-managed view. The + // carried commit graph is reclaimed as-is: the non-managed pass overwrites everything + // the managed attempt touched (refs re-refreshed, layout re-authored), and the stray + // managed-ws mark is only ever read for the entrypoint, provably a different commit here. + if entrypoint_reached { + return crate::workspace::project_workspace(managed_cg, ctx).map(Some); + } + cg = managed_cg; + } + let Some(head_tip) = head_tip else { + return Ok(None); + }; + let mut refs_by_id = overlay_repo.collect_ref_mapping_by_prefix(ref_prefixes(), &[])?; + let worktree_by_branch = + overlay_repo.worktree_branches(head_ref.as_ref().map(|r| r.as_ref()))?; + cg.refresh_refs(&mut refs_by_id, &worktree_by_branch); + let (cg, ctx) = assemble_unmanaged( + cg, + repo, + &overlay_repo, + &overlay_meta, + head_tip, + head_ref, + project_meta, + options, + )?; + crate::workspace::project_workspace(cg, ctx).map(Some) +} + +/// Build the managed-workspace's ref layout and context onto its `CommitGraph`, deriving the +/// enrichment inputs from `(repo, meta, project_meta)`. `overlay` refs and metadata are served +/// from memory (the [`Workspace::redo`](crate::Workspace::redo) path). +pub fn graph_from_repository( + repo: &gix::Repository, + meta: &T, + entrypoint: Option, + entrypoint_ref: Option, + project_meta: but_core::ref_metadata::ProjectMeta, + options: crate::walk::Options, + overlay: crate::walk::Overlay, +) -> anyhow::Result> { + let (overlay_repo, overlay_meta, _overlay_entrypoint) = overlay.into_parts(repo, meta); + let ws_ref: gix::refs::FullName = but_core::WORKSPACE_REF_NAME.try_into()?; + // No (usable) workspace ref means no managed workspace โ€” signal fall-through, don't fail: + // the dispatch routes any repository through here and builds non-managed on `Ok(None)`. + let Some(ws_commit) = resolve_ref_tip(&overlay_repo, ws_ref.as_ref())? else { + return Ok(None); + }; + let walk_tip = entrypoint.unwrap_or(ws_commit); + let walk_ref = if entrypoint.is_none() || entrypoint == Some(ws_commit) { + entrypoint_ref.clone().or(Some(ws_ref.clone())) + } else { + entrypoint_ref.clone() + }; + let cg = CommitGraph::from_walk( + &overlay_repo, + &overlay_meta, + walk_tip, + walk_ref.clone(), + project_meta.clone(), + options.clone(), + )?; + let ep = entrypoint.unwrap_or(ws_commit); + let (cg, ctx, entrypoint_reached) = assemble_managed( + cg, + repo, + &overlay_repo, + &overlay_meta, + &ws_ref, + ws_commit, + ep, + entrypoint_ref, + walk_ref.as_ref(), + project_meta, + options, + )?; + // The entrypoint never made it into a segment โ€” it wasn't reached by the traversal at all + // (outside entrypoints ARE covered, via their own region). Signal fall-through so the + // dispatch builds the non-managed view instead of returning an unusable graph. + if !entrypoint_reached { + return Ok(None); + } + Ok(Some((cg, ctx))) +} + +/// Build the ref layout and context for a NON-managed checkout โ€” a plain branch or detached +/// HEAD, with no `gitbutler/workspace` merge. `head_tip` is the checked-out commit (the graph's tip). +/// A detached HEAD is anonymized by `from_head`'s detach pass, not here. +#[allow(clippy::too_many_arguments)] +pub(crate) fn graph_from_repository_unmanaged( + repo: &gix::Repository, + meta: &T, + head_tip: gix::ObjectId, + entrypoint_ref: Option, + project_meta: but_core::ref_metadata::ProjectMeta, + options: crate::walk::Options, + overlay: crate::walk::Overlay, +) -> anyhow::Result<(CommitGraph, GraphContext)> { + let (overlay_repo, overlay_meta, _overlay_entrypoint) = overlay.into_parts(repo, meta); + let cg = CommitGraph::from_walk( + &overlay_repo, + &overlay_meta, + head_tip, + entrypoint_ref.clone(), + project_meta.clone(), + options.clone(), + )?; + assemble_unmanaged( + cg, + repo, + &overlay_repo, + &overlay_meta, + head_tip, + entrypoint_ref, + project_meta, + options, + ) +} + +/// Like [`graph_from_repository`], but seeded from explicit `tips`. The tips' +/// normalized traversal roles are carried onto the returned graph (`seeds`), which the +/// projection reads for tips-built graphs. +pub(crate) fn graph_from_repository_seeds( + repo: &gix::Repository, + meta: &T, + tips: Vec, + project_meta: but_core::ref_metadata::ProjectMeta, + options: crate::walk::Options, +) -> anyhow::Result<(CommitGraph, GraphContext)> { + let overlay = crate::walk::Overlay::default(); + let (overlay_repo, overlay_meta, _overlay_entrypoint) = overlay.into_parts(repo, meta); + let cg = CommitGraph::from_walk_seeds( + &overlay_repo, + &overlay_meta, + tips, + project_meta.clone(), + options.clone(), + )?; + let entrypoint = cg + .entrypoint() + .ok_or_else(|| anyhow::anyhow!("explicit seeds always contain an entrypoint"))?; + let entrypoint_ref = cg.entrypoint_ref().cloned(); + + // Managed only when the workspace ref resolves AND the tips traversal actually reached its + // commit โ€” explicit seeds define the graph's extent, they don't discover a workspace on their + // own. + let ws_ref: gix::refs::FullName = but_core::WORKSPACE_REF_NAME.try_into()?; + let ws_commit = + resolve_ref_tip(&overlay_repo, ws_ref.as_ref())?.filter(|c| cg.node(*c).is_some()); + + // Detachment travels on the seeds the walker carried over โ€” the projection + // reads it from there, no segment pass needed. + if let Some(ws_commit) = ws_commit { + // A workspace-ref entrypoint is the plain from_head case: no explicit entrypoint ref. + let ep_ref = entrypoint_ref + .clone() + .filter(|r| !but_core::is_workspace_ref_name(r.as_ref())); + let (cg, ctx, _entrypoint_reached) = assemble_managed( + cg, + repo, + &overlay_repo, + &overlay_meta, + &ws_ref, + ws_commit, + entrypoint, + ep_ref, + entrypoint_ref.as_ref(), + project_meta, + options, + )?; + Ok((cg, ctx)) + } else { + assemble_unmanaged( + cg, + repo, + &overlay_repo, + &overlay_meta, + entrypoint, + entrypoint_ref, + project_meta, + options, + ) + } +} + +/// The build context every phase reads โ€” one bundle from dispatch to materialization. +/// Phase artifacts (facts, plan, layout) travel separately, since later phases borrow them +/// alongside this context. +pub(super) struct BuildInputs<'a> { + pub cg: &'a CommitGraph, + /// The workspace tip in managed builds; the checked-out tip otherwise. + pub workspace_commit: gix::ObjectId, + pub entrypoint: gix::ObjectId, + pub entrypoint_ref: Option<&'a gix::refs::FullName>, + /// The target ref's resolved tip. + pub target: Option, + pub remote_tracking: &'a HashMap, + pub symbolic_remotes: &'a [String], + /// The in-workspace stack branch lists (managed builds only). + pub stack_branches: Option<&'a [Vec]>, + /// Ad-hoc same-tip branch orders routed through the chain plan. + pub ad_hoc_chains: &'a [Vec], + pub project_meta: &'a but_core::ref_metadata::ProjectMeta, + pub options: &'a crate::walk::Options, +} + +/// Assemble the MANAGED-workspace graph from `cg`: workspace metadata defines the chains, and the +/// enrichment reads go through the overlay views so in-memory previews (apply/unapply) see the +/// future state, not the on-disk one. +#[allow(clippy::too_many_arguments)] +fn assemble_managed( + mut cg: CommitGraph, + repo: &gix::Repository, + overlay_repo: &OverlayRepo<'_>, + overlay_meta: &OverlayMetadata<'_, T>, + ws_ref: &gix::refs::FullName, + ws_commit: gix::ObjectId, + entrypoint: gix::ObjectId, + entrypoint_ref: Option, + main_head_ref: Option<&gix::refs::FullName>, + project_meta: but_core::ref_metadata::ProjectMeta, + options: crate::walk::Options, +) -> anyhow::Result<(CommitGraph, GraphContext, bool)> { + // Two candidates, usually the same commit: the facts' managed-merge check reads the + // workspace tip, the ref layout's ws anchor reads the (checkout) entrypoint. + cg.mark_managed_ws_commit_by_message(repo, ws_commit); + cg.mark_managed_ws_commit_by_message(repo, entrypoint); + let ws_meta = overlay_meta.workspace(ws_ref.as_ref())?; + let stack_branches = in_workspace_stack_branches(&ws_meta); + let inputs = enrichment_inputs(repo, overlay_repo, &project_meta, main_head_ref)?; + assemble( + cg, + overlay_repo, + overlay_meta, + ws_commit, + entrypoint, + entrypoint_ref, + Some(&stack_branches), + inputs, + project_meta, + options, + ) +} + +/// Assemble the NON-managed graph from `cg`: no stack or workspace-ref passes, plus the +/// persisted single-branch ordering. +#[allow(clippy::too_many_arguments)] +fn assemble_unmanaged( + mut cg: CommitGraph, + repo: &gix::Repository, + overlay_repo: &OverlayRepo<'_>, + overlay_meta: &OverlayMetadata<'_, T>, + head_tip: gix::ObjectId, + entrypoint_ref: Option, + project_meta: but_core::ref_metadata::ProjectMeta, + options: crate::walk::Options, +) -> anyhow::Result<(CommitGraph, GraphContext)> { + cg.mark_managed_ws_commit_by_message(repo, head_tip); + let inputs = enrichment_inputs(repo, overlay_repo, &project_meta, entrypoint_ref.as_ref())?; + let (cg, ctx, _entrypoint_reached) = assemble( + cg, + overlay_repo, + overlay_meta, + head_tip, + head_tip, + entrypoint_ref, + None, + inputs, + project_meta, + options, + )?; + Ok((cg, ctx)) +} + +/// The build pipeline both entries share: plan, author the segments, apply persisted +/// ad-hoc orders, derive and install the stored ref positions, stamp the REQUESTED +/// entrypoint ref on the seed (it names the entry even where the walker nulled it for +/// ambiguity), enrich โ€” and capture the segments' two verdicts before they are +/// dropped: the entry's empty-chain resolution and whether the entrypoint landed in +/// a segment at all (the managed fall-through signal). `stack_branches` being present +/// is what MAKES a build managed. +#[allow(clippy::too_many_arguments)] +fn assemble( + mut cg: CommitGraph, + overlay_repo: &OverlayRepo<'_>, + overlay_meta: &OverlayMetadata<'_, T>, + workspace_commit: gix::ObjectId, + entrypoint: gix::ObjectId, + entrypoint_ref: Option, + stack_branches: Option<&[Vec]>, + inputs: EnrichmentInputs, + project_meta: but_core::ref_metadata::ProjectMeta, + options: crate::walk::Options, +) -> anyhow::Result<(CommitGraph, GraphContext, bool)> { + // The persisted ad-hoc branch order is a CHAIN: it threads through the same plan + // machinery as workspace metadata stacks. + let ad_hoc_orders = ad_hoc::ad_hoc_branch_stack_upgrades( + entrypoint_ref.as_ref(), + entrypoint_ref + .as_ref() + .and_then(|r| cg.commit_by_ref(r.as_ref())) + .is_some(), + overlay_repo, + overlay_meta, + )?; + let b = BuildInputs { + cg: &cg, + workspace_commit, + entrypoint, + entrypoint_ref: entrypoint_ref.as_ref(), + target: inputs.target, + remote_tracking: &inputs.remote_tracking, + symbolic_remotes: &inputs.symbolic_remotes, + stack_branches, + ad_hoc_chains: &ad_hoc_orders.same_tip_chains, + project_meta: &project_meta, + options: &options, + }; + let (f, plan, layout_plan) = plan::gather_and_plan(&b, overlay_meta); + let segment_data = graph_from_commit_graph(&b, overlay_meta, f, &plan, &layout_plan); + let ad_hoc_branch_stack_orders = ad_hoc_orders.full_orders; + let mut layout = layout::derive_ref_layout(&segment_data, &cg)?; + layout.empty_chain_anchors = layout_plan.empty_chain_anchors(); + cg.layout = Some(layout); + if let Some(name) = entrypoint_ref.as_ref() + && let Some(seed) = cg + .seeds + .iter_mut() + .find(|s| s.is_entrypoint && !s.is_detached) + { + seed.ref_name = Some(name.clone()); + } + let (branch_details, workspace_meta) = + segment_data::enrich(&cg, &segment_data, overlay_meta, &inputs.worktree_by_branch); + let entry_resolved_commit = segment_data.resolve_entrypoint_commit(&cg); + let entrypoint_reached = segment_data.entrypoint_sidx.is_some(); + let ctx = GraphContext { + options, + project_meta, + symbolic_remote_names: inputs.symbolic_remotes, + remote_tracking: inputs.remote_tracking, + ad_hoc_branch_stack_orders, + branch_details, + workspace_meta, + entry_resolved_commit, + }; + Ok((cg, ctx, entrypoint_reached)) +} + +/// The write-through seam's external-context refresh: `tip` (a stored/extra target, or a +/// remote-tracking tip) still exists on disk even when the editor dropped its node or rewrote +/// it in place. Revive tombstones and append any missing region โ€” walking the odb from `tip` +/// down to commits the graph knows โ€” with `flags` per the walk's conventions (Integrated for +/// target-seeded tips, empty for remote-ahead regions). A stale (unresolvable) tip is ignored, +/// like the walk does. +fn ensure_tip_region( + cg: &mut CommitGraph, + repo: &OverlayRepo<'_>, + tip: gix::ObjectId, + flags: crate::CommitFlags, +) -> anyhow::Result<()> { + let mut to_add = Vec::new(); + let mut queue = vec![tip]; + let mut seen = HashSet::new(); + while let Some(id) = queue.pop() { + if !seen.insert(id) || cg.index_of(id).is_some() { + continue; + } + cg.revive(id); + if cg.index_of(id).is_some() { + continue; + } + let Ok(commit) = repo.find_commit(id) else { + return Ok(()); + }; + let parents: Vec<_> = commit.parent_ids().map(|p| p.detach()).collect(); + queue.extend(parents.iter().copied()); + to_add.push((id, parents)); + } + let indices: Vec<_> = to_add + .iter() + .map(|(id, _)| cg.add_node(Some(*id))) + .collect(); + for ((_, parents), &idx) in to_add.iter().zip(&indices) { + let parent_indices = parents + .iter() + .map(|p| { + cg.index_of(*p) + .expect("parent was added or already present") + }) + .collect(); + cg.set_parents(idx, parent_indices); + cg.set_flags(idx, flags); + } + Ok(()) +} + +/// The remote half of the seam's external-context refresh: the walk traverses the AHEAD regions +/// of remote-tracking branches, so when an editor rewrite made a remote tip's commit vanish from +/// the arena (the local advanced past it), re-append that region from the odb. Only remotes of +/// locals the graph actually contains matter โ€” anything else the walk wouldn't reach either. +fn ensure_remote_regions( + cg: &mut CommitGraph, + repo: &gix::Repository, + overlay_repo: &OverlayRepo<'_>, + project_meta: &but_core::ref_metadata::ProjectMeta, +) -> anyhow::Result<()> { + let (remote_tracking, _symbolic_remotes) = + remote_tracking_from_repository(repo, overlay_repo, project_meta)?; + for (local, remote) in remote_tracking { + let local_tip = resolve_ref_tip(overlay_repo, local.as_ref())?; + if local_tip.is_none_or(|tip| cg.index_of(tip).is_none()) { + continue; + } + let Some(remote_tip) = resolve_ref_tip(overlay_repo, remote.as_ref())? else { + continue; + }; + ensure_tip_region(cg, overlay_repo, remote_tip, crate::CommitFlags::empty())?; + } + Ok(()) +} + +/// The commit `name` peels to through the overlay, `None` when the ref is absent +/// or unpeelable. +fn resolve_ref_tip( + repo: &OverlayRepo<'_>, + name: &gix::refs::FullNameRef, +) -> anyhow::Result> { + Ok(repo + .try_find_reference(name)? + .and_then(|mut r| r.peel_to_commit().ok()) + .map(|c| c.id().detach())) +} + +/// The enrichment inputs every builder entry derives from `(repo, project_meta)` and the overlay +/// views. +struct EnrichmentInputs { + /// The target commit, resolved from the CALLER's project meta for the builder's boundaries; + /// a default `ProjectMeta` means no target (no hard-coded `origin/main` fallback), like the + /// walk. Integration marks and `NotInRemote` already come from the walk โ€” no re-flagging. + target: Option, + /// Remote-tracking relationships come from git CONFIG plus the caller's project meta โ€” + /// overlay refs don't reshape them. + remote_tracking: HashMap, + symbolic_remotes: Vec, + /// Which worktree (if any) checks out each ref โ€” the main worktree `[๐ŸŒณ]` and any linked + /// worktrees `[๐Ÿ“]`, keyed by ref name. + worktree_by_branch: BTreeMap>, +} + +#[tracing::instrument(level = "trace", skip_all)] +fn enrichment_inputs( + repo: &gix::Repository, + overlay_repo: &OverlayRepo<'_>, + project_meta: &but_core::ref_metadata::ProjectMeta, + // The main-HEAD referent (like the walk's `graph.entrypoint_ref`): an overlay may override + // HEAD onto the workspace ref for a future-state preview, which the dispatched + // `entrypoint_ref` (None for workspace tips) would lose. + main_head_ref: Option<&gix::refs::FullName>, +) -> anyhow::Result { + let target = project_meta.target_ref.clone().and_then(|tr| { + Some( + overlay_repo + .try_find_reference(tr.as_ref()) + .ok()?? + .peel_to_commit() + .ok()? + .id() + .detach(), + ) + }); + let (remote_tracking, symbolic_remotes) = + remote_tracking_from_repository(repo, overlay_repo, project_meta)?; + let worktree_by_branch = overlay_repo.worktree_branches(main_head_ref.map(|r| r.as_ref()))?; + Ok(EnrichmentInputs { + target, + remote_tracking, + symbolic_remotes, + worktree_by_branch, + }) +} + +/// Only IN-WORKSPACE stacks form chains. An inactive/outside stack's branches never splice as +/// empty segments (`unapplied_branch_on_base`: "This will be an empty workspace") โ€” they +/// contribute only branch METADATA, which names commit-holding segments via the metadata tier +/// of disambiguation. A branch listed in SEVERAL stacks counts once, like the walk, which +/// ignores duplicate stack branch tips. +fn in_workspace_stack_branches( + ws: &but_core::ref_metadata::Workspace, +) -> Vec> { + let mut seen_branches = HashSet::new(); + ws.stacks + .iter() + .filter(|s| s.is_in_workspace()) + .map(|s| { + s.branches + .iter() + .map(|b| b.ref_name.clone()) + .filter(|b| seen_branches.insert(b.clone())) + .collect() + }) + .collect() +} + +/// The local branch that names the segment at `c`, mirroring the walk's tiers: ABOVE the base +/// the unique branch with GitButler METADATA wins (a stack branch beats the target's local ref, +/// e.g. `A` over `main`); at/below the base (Integrated) the target's local position wins +/// instead. Then the unique REMOTE-TRACKED branch (e.g. `main` over a plain `new-A`), then the +/// only branch, else anonymous. +fn disambiguated_ref( + cg: &CommitGraph, + c: gix::ObjectId, + remote_tracking: &HashMap, + meta: &T, + // The workspace commit, when naming happens in a managed workspace: the target-local + // tie-break applies only to its direct parents (chain tops). + workspace_commit: Option, + target_ref: Option<&gix::refs::FullName>, +) -> Option { + disambiguated_ref_at( + cg, + cg.index_of(c)?, + remote_tracking, + meta, + workspace_commit, + target_ref, + ) +} + +/// [`disambiguated_ref`] by node handle โ€” the boundary scan calls this once per in-set commit. +fn disambiguated_ref_at( + cg: &CommitGraph, + c: usize, + remote_tracking: &HashMap, + meta: &T, + workspace_commit: Option, + target_ref: Option<&gix::refs::FullName>, +) -> Option { + let node = cg.node_at(c); + let branches: Vec<&gix::refs::FullName> = node + .refs + .iter() + .map(|r| &r.ref_name) + .filter(|rn| is_plain_local_branch(rn)) + .collect(); + // Most commits carry no refs at all โ€” this runs per commit in the boundary scan. + if branches.is_empty() { + return None; + } + let unique = |pred: &dyn Fn(&gix::refs::FullName) -> bool| { + let mut it = branches.iter().copied().filter(|r| pred(r)); + it.next().filter(|_| it.next().is_none()).cloned() + }; + let integrated = node.flags.contains(crate::CommitFlags::Integrated); + (!integrated) + .then(|| unique(&|r| segment_metadata(r.as_ref(), meta).is_some())) + .flatten() + .or_else(|| unique(&|r| remote_tracking.contains_key(r))) + // Several remote-tracked branches on a STACK TOP (a direct parent of the workspace merge): + // a unique branch WITH metadata wins even when integrated (it is the chain the user works + // in, e.g. `first-branch` next to a target-local `main` in gb-local mode); among several + // metadata branches the TARGET's own local wins (e.g. `main` next to a just-applied + // branch, both resting on the target's tip). Deeper commits stay anonymous like the walk's. + .or_else(|| { + workspace_commit + .is_some_and(|ws| cg.children_at(c).iter().any(|&k| cg.id_at(k) == ws)) + .then(|| { + unique(&|r| segment_metadata(r.as_ref(), meta).is_some()) + .or_else(|| unique(&|r| remote_tracking.get(r) == target_ref)) + }) + .flatten() + }) + .or_else(|| unique(&|_| true)) +} + +/// Whether `name` is one of the workspace metadata's stack branches. +pub(super) fn is_stack_branch( + stack_branches: Option<&[Vec]>, + name: &gix::refs::FullName, +) -> bool { + stack_branches + .into_iter() + .flatten() + .flatten() + .any(|b| b == name) +} + +fn is_plain_local_branch(rn: &gix::refs::FullName) -> bool { + let rn = rn.as_ref(); + // Only the workspace ref itself is special; other `gitbutler/*` refs (e.g. `gitbutler/target`) + // name segments like any branch, matching the walk. + rn.category() == Some(Category::LocalBranch) && !but_core::is_workspace_ref_name(rn) +} + +/// All remote-tracking refs on commits in the graph, in name order. +fn remote_refs(cg: &CommitGraph) -> std::collections::BTreeSet { + cg.commit_ids() + .flat_map(|c| cg.refs_at(c)) + .filter(|r| r.as_ref().category() == Some(Category::RemoteBranch)) + .collect() +} + +/// The segment metadata for a ref: `Branch` for a tracked branch, `Workspace` for the workspace ref, +/// `None` otherwise (mirrors `extract_local_branch_metadata`). +fn segment_metadata( + ref_name: &gix::refs::FullNameRef, + meta: &T, +) -> Option { + if ref_name.category() != Some(Category::LocalBranch) { + return None; + } + // The workspace ref is a WORKSPACE, never a branch โ€” stray branch metadata under its name + // (e.g. an overlay that pre-writes branch data for a ref about to be created) must not + // reclassify the workspace segment, which the projection finds by its metadata. + if but_core::is_workspace_ref_name(ref_name) { + return meta + .workspace_opt(ref_name) + .ok() + .flatten() + .map(|ws| crate::SegmentMetadata::Workspace((*ws).clone())); + } + if let Ok(Some(branch)) = meta.branch_opt(ref_name) { + return Some(crate::SegmentMetadata::Branch((*branch).clone())); + } + if let Ok(Some(ws)) = meta.workspace_opt(ref_name) { + return Some(crate::SegmentMetadata::Workspace((*ws).clone())); + } + None +} diff --git a/crates/but-graph/src/build/plan.rs b/crates/but-graph/src/build/plan.rs new file mode 100644 index 00000000000..ad2d2cb9177 --- /dev/null +++ b/crates/but-graph/src/build/plan.rs @@ -0,0 +1,895 @@ +//! Phase 2 of gather-then-build: the chain plan โ€” names, floats, demotions, and +//! ref order decided purely, before any segment exists. + +use std::collections::{HashMap, HashSet}; + +use gix::reference::Category; + +use super::facts::{Facts, facts}; +use super::remotes::remote_name_in_play; +use super::{IdMap, IdSet, disambiguated_ref, is_plain_local_branch}; +use crate::CommitGraph; + +/// The authored ref PLACEMENT table: which references sit on which commit, grouped and +/// ordered, one chain per metadata stack list. Build-internal โ€” the chain-structure pass +/// consumes it directly, and the stored [`RefLayout`](crate::ref_layout::RefLayout) is +/// derived from the segments it shapes. +#[derive(Debug, PartialEq, Eq, Clone, Default)] +pub(super) struct LayoutPlan { + /// The groups anchored on each commit, in chain-threading order. + pub(super) at_commit: HashMap>, + /// One chain per metadata stack list, in metadata order. + pub(super) chains: Vec, + /// Commits kept anonymous (sorted): a shared base loses its build-time name so every + /// chain's branches float above it as their own chain. + pub(super) anonymous_bases: Vec, +} + +impl LayoutPlan { + /// Where the empty chains rest โ€” the stored layout's + /// [`empty_chain_anchors`](crate::ref_layout::RefLayout::empty_chain_anchors): each + /// chain's first non-[`Skipped`](GroupPlacement::Skipped) anchor, when its placement + /// is a splice (a chain with commits of its own is placed as a run, not an anchor). + pub(super) fn empty_chain_anchors(&self) -> Vec { + self.chains + .iter() + .filter_map(|chain| { + let (commit, gi) = chain.anchors.iter().find(|(commit, gi)| { + self.at_commit + .get(commit) + .and_then(|groups| groups.get(*gi)) + .is_some_and(|g| g.placement != GroupPlacement::Skipped) + })?; + match self.at_commit[commit][*gi].placement { + GroupPlacement::Splice { + into_owning_chain, .. + } => Some(crate::ref_layout::EmptyChainAnchor { + commit: *commit, + joins_owning_chain: into_owning_chain, + }), + _ => None, + } + }) + .collect() + } +} + +/// A tip's planned name: the ref that names the tip's segment, and the commit it names. +#[derive(Clone)] +pub(super) struct NamedTip { + pub(super) name: gix::refs::FullName, + pub(super) tip: gix::ObjectId, +} + +/// One metadata stack list's groups, in metadata order (top โ†’ bottom). Each anchor is +/// `(commit, index into LayoutPlan::at_commit[commit])` โ€” the index keeps chains +/// apart when several chains anchor groups on the same commit. +#[derive(Debug, PartialEq, Eq, Clone, Default)] +pub(super) struct RefChain { + /// The chain's anchors in metadata order. + pub(super) anchors: Vec<(gix::ObjectId, usize)>, +} + +/// One same-commit group of references anchored on a commit. +#[derive(Debug, PartialEq, Eq, Clone)] +pub(super) struct LayoutGroup { + /// The member naming the anchor commit's segment, when the group names it at all. + pub(super) naming_ref: Option, + /// How the group's members land on the graph. + pub(super) placement: GroupPlacement, +} + +/// The group member that NAMES the anchor commit's segment. +#[derive(Debug, PartialEq, Eq, Clone)] +pub(super) struct NamingRef { + /// The naming reference. + pub(super) name: gix::refs::FullName, + /// The metadata-order override: this ref displaced a build-time name belonging to the + /// group, whose remote link moves to its floated empty segment instead. + pub(super) clear_remote: bool, +} + +/// How a group's members land relative to the commit the group anchors on. +#[derive(Debug, PartialEq, Eq, Clone)] +pub(super) enum GroupPlacement { + /// The group is outside the workspace or co-located with a managed merge commit โ€” nothing + /// is created, naming ref included. Kept so group ordinals stay aligned between plan and + /// build. + Skipped, + /// Another chain owns the (non-integrated) commit: the members stay passive on it. + Passive(Vec), + /// The non-naming members become empty segments spliced above the anchor. + Splice { + /// The spliced members, in metadata order. + members: Vec, + /// The anchor commit is inside another chain, so the empties splice into it; otherwise + /// the group anchors its own chain from the workspace (shared base or integrated + /// anchor). + into_owning_chain: bool, + }, +} + +/// Phases 1+2 for one build: the facts, the chain plan decided over them, and the authored +/// ref placement table โ€” the artifacts materialization consumes. +#[tracing::instrument(level = "trace", skip_all)] +pub(super) fn gather_and_plan( + b: &super::BuildInputs<'_>, + meta: &T, +) -> (Facts, ChainPlan, LayoutPlan) { + let f = facts(b, meta); + // The base all chains and the (stored/extra) target converge on, extended down to an + // older target position. + let ws_lower_bound = effective_lower_bound( + b.cg, + b.workspace_commit, + b.target, + b.project_meta, + b.options, + ); + let (plan, layout) = chain_plan(b, &f, ws_lower_bound, meta); + (f, plan, layout) +} + +/// One floated chain placeholder decided by [`chain_plan`]. +pub(super) struct Float { + /// The commit whose segment goes anonymous so the empty named segment can float above it. + pub(super) tip: gix::ObjectId, + /// The name given to the empty segment spliced in between the workspace and `tip`. + pub(super) name: gix::refs::FullName, + /// A build-time name pushed aside by a metadata stack branch; it returns to `tip`'s + /// commit as a passive ref. `None` when nothing was displaced. + pub(super) displaced_ref_name: Option, +} + +pub(super) struct ChainPlan { + pub(super) floats: Vec, + pub(super) anonymous_bases: IdSet, + /// Every boundary tip's MATERIALIZATION name, before floats/demotions suppress it. The + /// remote/target passes key their decisions on these โ€” they evaluate the pre-chain view. + pub(super) base_name_of: IdMap, + /// Names the remote/target/explicit-tip passes give to ANONYMOUS boundary tips, modeled + /// in pass order so materialization can mint segments with their FINAL names; the passes + /// only add links. The value carries the named ref's actual position (a behind remote can + /// point mid-run, below the owner's tip). + pub(super) renames: IdMap<(gix::refs::FullName, gix::ObjectId)>, + /// Every remote-ref name the remote passes will consume (renames, empty roots, ahead + /// regions, untracked surfacing, the target). With the chain structure built FIRST, the + /// empties filter consults this instead of finding the remote segments in the graph. + pub(super) remote_used: HashSet, +} + +impl ChainPlan { + /// The name `tip`'s segment mints with, and the commit that name resolves to: floated and + /// anonymized tips stay anonymous, otherwise the build-time name or the planned rename. + pub(super) fn tip_name(&self, tip: gix::ObjectId) -> Option { + if self.floats.iter().any(|fl| fl.tip == tip) || self.anonymous_bases.contains(&tip) { + return None; + } + self.base_name_of + .get(&tip) + .map(|n| NamedTip { + name: n.clone(), + tip, + }) + .or_else(|| { + let (name, tip) = self.renames.get(&tip).cloned()?; + Some(NamedTip { name, tip }) + }) + } +} + +/// The managed chain NAME decisions, computed before any segment mutation (phase 2 of +/// gather-then-build). Models the naming state the passes would see and decides purely: which +/// shared workspace-parent tips float their name up as an empty placeholder +/// (`float_shared_stack_tips`), which anchors are DEMOTED to anonymous so their stacks' +/// branches form their own chains (`anonymize_shared_bases`), and the group naming and ref +/// order `insert_empty_branches` consumes as data (`thread_ref_groups`). +#[tracing::instrument(level = "trace", skip_all)] +pub(super) fn chain_plan( + b: &super::BuildInputs<'_>, + facts: &Facts, + ws_lower_bound: Option, + meta: &T, +) -> (ChainPlan, LayoutPlan) { + let &super::BuildInputs { + cg, + workspace_commit, + entrypoint, + entrypoint_ref, + target, + remote_tracking, + symbolic_remotes, + stack_branches, + project_meta, + options, + .. + } = b; + let target_ref = project_meta.target_ref.as_ref(); + let extra_target = options.extra_target_commit_id; + let mut plan = ChainPlan { + floats: Vec::new(), + anonymous_bases: IdSet::default(), + base_name_of: IdMap::default(), + renames: IdMap::default(), + remote_used: HashSet::new(), + }; + let mut layout = LayoutPlan::default(); + // Materialization names firstโ€ฆ + let mut name_of = materialization_names( + cg, + facts, + workspace_commit, + entrypoint, + entrypoint_ref, + remote_tracking, + meta, + target_ref, + ); + plan.base_name_of = name_of.clone(); + // โ€ฆthen the remote, untracked-remote, target, and explicit-tip renames, in pass order. + let mut remote_used = model_remote_renames( + cg, + facts, + &mut name_of, + &mut plan.renames, + remote_tracking, + stack_branches, + symbolic_remotes, + ); + model_untracked_remotes(cg, facts, &name_of, remote_tracking, &mut remote_used); + model_target_rename( + cg, + facts, + &mut name_of, + &mut plan.renames, + target_ref, + &mut remote_used, + ); + model_explicit_tip_renames(cg, facts, &mut name_of, &mut plan.renames); + + if stack_branches.is_none() && b.ad_hoc_chains.is_empty() { + plan.remote_used = remote_used; + layout.anonymous_bases = plan.anonymous_bases.iter().copied().collect(); + layout.anonymous_bases.sort(); + return (plan, layout); + } + + float_shared_stack_tips( + cg, + facts, + workspace_commit, + target, + stack_branches, + &mut name_of, + &mut plan.floats, + ); + + let lists = stack_branches.unwrap_or(&[]); + let combined: Vec> = lists + .iter() + .chain(b.ad_hoc_chains.iter()) + .cloned() + .collect(); + let lists_per_commit = stack_lists_per_commit(cg, &combined); + let at_or_below_bound: Option = ws_lower_bound.map(|lb| cg.ancestor_set(lb)); + anonymize_shared_bases( + cg, + facts, + lists, + &lists_per_commit, + at_or_below_bound.as_ref(), + ws_lower_bound, + &mut name_of, + &mut plan.anonymous_bases, + ); + let mut used = names_in_use( + cg, + facts, + &name_of, + &plan.floats, + &remote_used, + lists, + remote_tracking, + meta, + target_ref, + extra_target, + ); + thread_ref_groups( + cg, + facts, + workspace_commit, + ws_lower_bound, + lists, + b.ad_hoc_chains, + &lists_per_commit, + at_or_below_bound.as_ref(), + &mut name_of, + &mut used, + &remote_used, + &mut layout, + ); + plan.remote_used = remote_used; + layout.anonymous_bases = plan.anonymous_bases.iter().copied().collect(); + layout.anonymous_bases.sort(); + (plan, layout) +} + +/// Every boundary tip's materialization name โ€” the naming state the chain passes start from. +#[allow(clippy::too_many_arguments)] +fn materialization_names( + cg: &CommitGraph, + facts: &Facts, + workspace_commit: gix::ObjectId, + entrypoint: gix::ObjectId, + entrypoint_ref: Option<&gix::refs::FullName>, + remote_tracking: &HashMap, + meta: &T, + target_ref: Option<&gix::refs::FullName>, +) -> IdMap { + let mut name_of: IdMap = IdMap::default(); + for &tip in &facts.tips { + if let Some(name) = materialize_tip_name( + cg, + tip, + workspace_commit, + facts.ws_is_managed_merge, + facts.entrypoint_forced_boundary.then_some(entrypoint), + entrypoint_ref, + remote_tracking, + meta, + target_ref, + ) { + name_of.insert(tip, name); + } + } + name_of +} + +/// The anon-owner renames of `add_remote_segments` (a remote pointing BEHIND/at an anonymous +/// in-set segment names it), in materialization order like the pass. Every remote name the +/// pass consumes is tracked, because the target block only runs when nothing already used the +/// target ref. +fn model_remote_renames( + cg: &CommitGraph, + facts: &Facts, + name_of: &mut IdMap, + renames: &mut IdMap<(gix::refs::FullName, gix::ObjectId)>, + remote_tracking: &HashMap, + stack_branches: Option<&[Vec]>, + symbolic_remotes: &[String], +) -> HashSet { + let in_play = |rt: &gix::refs::FullName| remote_name_in_play(rt, symbolic_remotes); + let mut remote_used: HashSet = HashSet::new(); + for &tip in &facts.tips { + let Some(remote_ref) = name_of.get(&tip).and_then(|n| remote_tracking.get(n)) else { + continue; + }; + let Some(remote_tip) = cg.commit_by_ref(remote_ref.as_ref()) else { + continue; + }; + if facts.in_set.contains(&remote_tip) { + let owner = facts + .owner_of + .get(&remote_tip) + .copied() + .unwrap_or(remote_tip); + if let gix::hashtable::hash_map::Entry::Vacant(e) = name_of.entry(owner) { + e.insert(remote_ref.clone()); + renames.insert(owner, (remote_ref.clone(), remote_tip)); + } + remote_used.insert(remote_ref.clone()); + } else if in_play(remote_ref) && !super::is_stack_branch(stack_branches, remote_ref) { + remote_used.insert(remote_ref.clone()); + } + } + remote_used +} + +/// The untracked-remote pass surfacing remotes whose local counterpart shares the commit. +fn model_untracked_remotes( + cg: &CommitGraph, + facts: &Facts, + name_of: &IdMap, + remote_tracking: &HashMap, + remote_used: &mut HashSet, +) { + let named: HashSet<&gix::refs::FullName> = name_of.values().collect(); + for r in super::remote_refs(cg) { + if remote_used.contains(&r) || named.contains(&r) { + continue; + } + let Some(tip) = cg.commit_by_ref(r.as_ref()) else { + continue; + }; + if facts.in_set.contains(&tip) + && cg + .refs_at(tip) + .iter() + .any(|l| remote_tracking.get(l) == Some(&r)) + { + remote_used.insert(r); + } + } +} + +/// The target pass naming an anonymous in-set owner after the target ref โ€” only when nothing +/// already used it. +fn model_target_rename( + cg: &CommitGraph, + facts: &Facts, + name_of: &mut IdMap, + renames: &mut IdMap<(gix::refs::FullName, gix::ObjectId)>, + target_ref: Option<&gix::refs::FullName>, + remote_used: &mut HashSet, +) { + if let Some(tr) = target_ref + && tr.as_ref().category() == Some(Category::RemoteBranch) + && !remote_used.contains(tr) + && !name_of.values().any(|n| n == tr) + && let Some(tip) = cg.commit_by_ref(tr.as_ref()) + { + if facts.in_set.contains(&tip) { + let owner = facts.owner_of.get(&tip).copied().unwrap_or(tip); + if let gix::hashtable::hash_map::Entry::Vacant(e) = name_of.entry(owner) { + e.insert(tr.clone()); + renames.insert(owner, (tr.clone(), tip)); + } + } + remote_used.insert(tr.clone()); + } +} + +/// The explicit-tip pass naming anonymous segments that START at a tip. +fn model_explicit_tip_renames( + cg: &CommitGraph, + facts: &Facts, + name_of: &mut IdMap, + renames: &mut IdMap<(gix::refs::FullName, gix::ObjectId)>, +) { + for t in cg.seeds.iter().filter(|_| cg.explicit_seeds) { + let Some(ref_name) = t.ref_name.clone() else { + continue; + }; + if but_core::is_workspace_ref_name(ref_name.as_ref()) + || name_of.values().any(|n| *n == ref_name) + { + continue; + } + if facts.boundaries.contains(&t.id) + && let gix::hashtable::hash_map::Entry::Vacant(e) = name_of.entry(t.id) + { + e.insert(ref_name.clone()); + renames.insert(t.id, (ref_name, t.id)); + } + } +} + +/// A workspace-parent tip whose commit another in-workspace commit builds on goes ANONYMOUS, +/// its name floating above as an empty chain placeholder โ€” the unique metadata STACK branch +/// when build-time disambiguation picked a non-stack ref (which then returns to the commit as +/// a passive ref). +fn float_shared_stack_tips( + cg: &CommitGraph, + facts: &Facts, + workspace_commit: gix::ObjectId, + target: Option, + stack_branches: Option<&[Vec]>, + name_of: &mut IdMap, + floats: &mut Vec, +) { + if !facts.ws_is_managed_merge { + return; + } + for parent in cg.parents(workspace_commit) { + // The target/base chain keeps its name even when other stacks depend on it. + if Some(parent) == target || !facts.boundaries.contains(&parent) { + continue; + } + let Some(current) = name_of.get(&parent).cloned() else { + continue; + }; + // Shared iff some other IN-WORKSPACE commit's first parent is this tip. + let shared = facts.in_set.iter().any(|&c| { + c != workspace_commit + && cg.first_parent(c) == Some(parent) + && cg + .node(c) + .is_some_and(|n| n.flags.contains(crate::CommitFlags::InWorkspace)) + }); + if !shared { + continue; + } + // Float the unique metadata STACK branch over a build-time non-stack pick: an + // applied-but-empty stack must keep its own chain, or the projection's + // integration-prune swallows the whole stack with the shared base it would own. + let (float_name, displaced) = if super::is_stack_branch(stack_branches, ¤t) { + (current.clone(), None) + } else { + let mut stack_refs = cg + .refs_at(parent) + .into_iter() + .filter(|r| is_plain_local_branch(r) && super::is_stack_branch(stack_branches, r)); + match (stack_refs.next(), stack_refs.next()) { + (Some(stack_ref), None) + if !name_of.values().any(|n| *n == stack_ref) + && !floats.iter().any(|f| f.name == stack_ref) => + { + (stack_ref, Some(current.clone())) + } + _ => (current.clone(), None), + } + }; + name_of.remove(&parent); + floats.push(Float { + tip: parent, + name: float_name, + displaced_ref_name: displaced, + }); + } +} + +/// How many metadata stack lists point (via any of their branches) at each commit. +fn stack_lists_per_commit(cg: &CommitGraph, lists: &[Vec]) -> IdMap { + let mut lists_per_commit: IdMap = IdMap::default(); + for list in lists { + let mut seen = HashSet::new(); + for b in list { + if let Some(c) = cg.commit_by_ref(b.as_ref()) + && seen.insert(c) + { + *lists_per_commit.entry(c).or_default() += 1; + } + } + } + lists_per_commit +} + +/// `insert_empty_branches`' demotions. A commit pointed at by branches of SEVERAL metadata +/// stacks at/below the bound is a shared base: its segment stays anonymous and every stack's +/// branches float above as their own chain. Likewise at the workspace LOWER BOUND, where +/// independent stacks rest: an otherwise-unrepresented stack's branch pointing there floats +/// as its own empty chain. +#[allow(clippy::too_many_arguments)] +fn anonymize_shared_bases( + cg: &CommitGraph, + facts: &Facts, + lists: &[Vec], + lists_per_commit: &IdMap, + at_or_below_bound: Option<&IdSet>, + ws_lower_bound: Option, + name_of: &mut IdMap, + anonymous_bases: &mut IdSet, +) { + for (&commit, &count) in lists_per_commit { + if count <= 1 { + continue; + } + if let Some(below) = at_or_below_bound + && !below.contains(&commit) + { + continue; + } + let anchor = facts.owner_of.get(&commit).copied().unwrap_or(commit); + if name_of + .get(&anchor) + .is_some_and(|n| lists.iter().flatten().any(|b| b == n)) + { + name_of.remove(&anchor); + anonymous_bases.insert(anchor); + } + } + if let Some(lb) = ws_lower_bound + && facts.boundaries.contains(&lb) + && name_of.get(&lb).is_some_and(|n| { + lists + .iter() + .any(|l| l.contains(n) && floats_at_lower_bound(cg, ws_lower_bound, l)) + }) + { + name_of.remove(&lb); + anonymous_bases.insert(lb); + } +} + +/// Does this metadata stack list rest at the workspace lower bound โ€” some branch at the bound +/// itself and every other resolved branch integrated? +fn floats_at_lower_bound( + cg: &CommitGraph, + ws_lower_bound: Option, + list: &[gix::refs::FullName], +) -> bool { + let Some(lb) = ws_lower_bound else { + return false; + }; + let mut at_lb = false; + for b in list { + match cg.commit_by_ref(b.as_ref()) { + Some(c) if c == lb => at_lb = true, + Some(c) + if cg + .node(c) + .is_some_and(|n| !n.flags.contains(crate::CommitFlags::Integrated)) => + { + return false; + } + _ => {} + } + } + at_lb +} + +/// The full set of names in use by `insert_empty_branches` time โ€” chain names plus everything +/// the remote/target/tip/advanced passes will have created โ€” because the group naming's "does +/// this ref already name a segment" ranges over every segment. +#[allow(clippy::too_many_arguments)] +fn names_in_use( + cg: &CommitGraph, + facts: &Facts, + name_of: &IdMap, + floats: &[Float], + remote_used: &HashSet, + lists: &[Vec], + remote_tracking: &HashMap, + meta: &T, + target_ref: Option<&gix::refs::FullName>, + extra_target: Option, +) -> HashSet { + let mut used: HashSet = name_of.values().cloned().collect(); + used.extend(floats.iter().map(|fl| fl.name.clone())); + used.extend(remote_used.iter().cloned()); + // The target ref always ends up naming something when it resolves. + if let Some(tr) = target_ref + && tr.as_ref().category() == Some(Category::RemoteBranch) + && cg.commit_by_ref(tr.as_ref()).is_some() + { + used.insert(tr.clone()); + } + // Explicit traversal seeds name a segment (an anon owner, an empty splice, or a region tip). + for t in cg.seeds.iter().filter(|_| cg.explicit_seeds) { + if let Some(rn) = t.ref_name.clone() + && !but_core::is_workspace_ref_name(rn.as_ref()) + && cg.node(t.id).is_some() + { + used.insert(rn); + } + } + // An extra target outside every region is surfaced named by the unique plain local on it. + if let Some(extra) = extra_target + && cg.node(extra).is_some() + && !facts.in_set.contains(&extra) + && let Some(l) = super::remotes::unique_plain_local(cg, extra) + { + used.insert(l); + } + // Advanced-outside branches (`add_advanced_outside_branches`), deduped by outside tip. + let mut adv_seen: IdSet = IdSet::default(); + for b in lists.iter().flatten() { + if !is_plain_local_branch(b) || used.contains(b) { + continue; + } + let Some(tip) = cg.commit_by_ref(b.as_ref()) else { + continue; + }; + if facts.in_set.contains(&tip) || !adv_seen.insert(tip) { + continue; + } + // The tip is outside (guarded above); it counts only when its spine rejoins the set. + if cg + .first_on_spine(tip, |c| facts.in_set.contains(&cg.id_at(c))) + .is_none() + { + continue; + } + if let Some(name) = disambiguated_ref(cg, tip, remote_tracking, meta, None, target_ref) { + used.insert(name); + } + } + used +} + +/// The group threading, mirroring `insert_empty_branches` exactly: per stack list, groups of +/// consecutive branches on one commit; the bottom-most member names an anonymous anchor, and +/// metadata order overrides a build-time name that belongs to the group. Authors the stored +/// layout's chains and commit-keyed groups directly. +#[allow(clippy::too_many_arguments)] +fn thread_ref_groups( + cg: &CommitGraph, + facts: &Facts, + workspace_commit: gix::ObjectId, + ws_lower_bound: Option, + lists: &[Vec], + ad_hoc_lists: &[Vec], + lists_per_commit: &IdMap, + at_or_below_bound: Option<&IdSet>, + name_of: &mut IdMap, + used: &mut HashSet, + remote_used: &HashSet, + layout: &mut LayoutPlan, +) { + let push_group = |layout: &mut LayoutPlan, + stored: &mut RefChain, + commit: gix::ObjectId, + naming_ref: Option, + placement: GroupPlacement| { + let groups = layout.at_commit.entry(commit).or_default(); + groups.push(LayoutGroup { + naming_ref, + placement, + }); + stored.anchors.push((commit, groups.len() - 1)); + }; + let all_lists = lists + .iter() + .map(|l| (l, false)) + .chain(ad_hoc_lists.iter().map(|l| (l, true))); + for (list, is_ad_hoc) in all_lists { + let list: Vec = list + .iter() + .filter(|b| cg.commit_by_ref(b.as_ref()).is_some()) + .cloned() + .collect(); + let mut stored = RefChain::default(); + let mut i = 0; + while i < list.len() { + let commit = cg.commit_by_ref(list[i].as_ref()); + let start = i; + while i < list.len() && cg.commit_by_ref(list[i].as_ref()) == commit { + i += 1; + } + let group = &list[start..i]; + let Some(commit) = commit else { continue }; + // Ad-hoc entry chains live in the ENTRY region, which may sit outside the + // workspace in-set โ€” their placement domain is wherever the walk put them. + if (!is_ad_hoc && !facts.in_set.contains(&commit)) + || (commit == workspace_commit && facts.ws_is_managed_merge) + { + push_group(layout, &mut stored, commit, None, GroupPlacement::Skipped); + continue; + } + let anchor = facts.owner_of.get(&commit).copied().unwrap_or(commit); + let mut naming = None; + let shared_commit_above_bound = lists_per_commit.get(&commit).copied().unwrap_or(0) > 1 + && at_or_below_bound.is_some_and(|below| !below.contains(&commit)); + if !name_of.contains_key(&anchor) + && (lists_per_commit.get(&commit).copied().unwrap_or(0) <= 1 + || shared_commit_above_bound) + && !(Some(commit) == ws_lower_bound + && floats_at_lower_bound(cg, ws_lower_bound, &list)) + && let Some(namer) = group.last() + && !used.contains(namer) + { + name_of.insert(anchor, namer.clone()); + used.insert(namer.clone()); + naming = Some(NamingRef { + name: namer.clone(), + clear_remote: false, + }); + } + if let Some(namer) = group.last() + && name_of + .get(&anchor) + .is_some_and(|n| n != namer && group.contains(n)) + { + // The override DISPLACES the anchor's build-time name: it re-enters the pool + // and splices as an empty group member. + if let Some(displaced) = name_of.get(&anchor) { + used.remove(displaced); + } + name_of.insert(anchor, namer.clone()); + used.insert(namer.clone()); + naming = Some(NamingRef { + name: namer.clone(), + clear_remote: true, + }); + } + // Every group member ends placed (naming the anchor or spliced as an empty) when the + // empties path runs; the cross-stack-owned skip leaves them passive instead. + let cross_stack_owned = lists_per_commit.get(&commit).copied().unwrap_or(0) > 1 + && name_of + .get(&anchor) + .is_some_and(|n| !list.contains(n) && lists.iter().any(|l| l.contains(n))); + let anchor_not_integrated = cg + .node(anchor) + .is_some_and(|n| !n.flags.contains(crate::CommitFlags::Integrated)); + // The RefOrder: which members become empties, and how the group lands. `used` at + // THIS point models materialization's "already names a segment" gate (the group + // naming ref included โ€” it names the anchor, not an empty). + let shared_base = lists_per_commit.get(&commit).copied().unwrap_or(0) > 1 + && at_or_below_bound.is_none_or(|below| below.contains(&commit)); + let members: Vec = group + .iter() + .filter(|b| !used.contains(*b) && !remote_used.contains(*b)) + .cloned() + .collect(); + let placement = if cross_stack_owned && anchor_not_integrated { + GroupPlacement::Passive(members) + } else { + used.extend(group.iter().cloned()); + GroupPlacement::Splice { + members, + into_owning_chain: !shared_base && anchor_not_integrated, + } + }; + push_group(layout, &mut stored, commit, naming, placement); + } + layout.chains.push(stored); + } +} + +/// The name a boundary tip gets at MATERIALIZATION โ€” shared with `chain_plan`'s modeling so +/// plan and build cannot drift. The managed workspace tip is named by the workspace ref +/// itself; a forced entrypoint boundary keeps the split's precedence (checked-out ref first); +/// every other tip is named by disambiguation. A truly detached HEAD is anonymized afterwards +/// by `from_head`'s detach pass, never here. +#[allow(clippy::too_many_arguments)] +pub(super) fn materialize_tip_name( + cg: &CommitGraph, + tip: gix::ObjectId, + workspace_commit: gix::ObjectId, + ws_is_managed_merge: bool, + forced_entrypoint: Option, + entrypoint_ref: Option<&gix::refs::FullName>, + remote_tracking: &HashMap, + meta: &T, + target_ref: Option<&gix::refs::FullName>, +) -> Option { + if tip == workspace_commit { + if ws_is_managed_merge { + // Named by EXACTLY the workspace ref: co-located transient `gitbutler/*` refs + // (e.g. `gitbutler/edit` mid edit-mode) must never name or join the workspace. + super::materialize::empty_workspace_ref(cg, tip) + } else { + // Name by disambiguation; the empty workspace segment is spliced in above later. + disambiguated_ref( + cg, + tip, + remote_tracking, + meta, + Some(workspace_commit), + target_ref, + ) + } + } else if forced_entrypoint == Some(tip) { + entrypoint_ref + .cloned() + .or_else(|| disambiguated_ref(cg, tip, remote_tracking, meta, None, target_ref)) + } else { + disambiguated_ref( + cg, + tip, + remote_tracking, + meta, + Some(workspace_commit), + target_ref, + ) + } +} + +/// The lower bound the PROJECTION will use: the merge base with the target, extended DOWN to a +/// stored/extra target position lying below it โ€” an older target location keeps the commits +/// integrated since then visible, so stacks resting between the bound and the merge base are real +/// (kept) stacks, not empty floats. +pub(super) fn effective_lower_bound( + cg: &CommitGraph, + workspace_commit: gix::ObjectId, + target: Option, + project_meta: &but_core::ref_metadata::ProjectMeta, + options: &crate::walk::Options, +) -> Option { + let mut lb = target + .or(project_meta.target_commit_id) + .or(options.extra_target_commit_id) + .and_then(|t| cg.lowest_common_base(workspace_commit, t))?; + for candidate in [ + project_meta.target_commit_id, + options.extra_target_commit_id, + ] + .into_iter() + .flatten() + { + if candidate != lb && cg.ancestor_set(lb).contains(&candidate) { + lb = candidate; + } + } + Some(lb) +} diff --git a/crates/but-graph/src/build/remote_segments.rs b/crates/but-graph/src/build/remote_segments.rs new file mode 100644 index 00000000000..0d134c15db1 --- /dev/null +++ b/crates/but-graph/src/build/remote_segments.rs @@ -0,0 +1,401 @@ +//! The remote passes of the build: locals' remote-tracking counterparts become segments โ€” +//! behind/at remotes as empty roots into the owning segment, ahead remotes segmented region +//! by region ([`segment_ahead_region`], also the region-grower for extra targets, outside +//! entrypoints, and explicit seeds) โ€” plus the untracked same-tip remotes, the target remote's +//! surfacing, and the co-located remote empties. Every pass mutates the row arena +//! ([`SegmentData`]) and wires sibling/tracking links as it creates rows. + +use std::collections::{HashMap, HashSet}; + +use gix::reference::Category; + +use super::materialize::commit_run; +use super::plan::ChainPlan; +use super::remotes::{AheadRegion, region_tips, remote_name_in_play, unique_plain_local}; +use super::segment_data::SegmentData; +use super::{IdMap, IdSet, is_plain_local_branch}; +use crate::CommitGraph; + +/// The remote pass: locals keyed on the plan's pre-chain names in link order, behind/at +/// remotes as empty roots (skipped when the plan's rename already named the owner โ€” that +/// owner still gets the sibling/tracking links), ahead remotes segmented region by region. +#[allow(clippy::too_many_arguments)] +#[tracing::instrument(level = "trace", skip_all)] +pub(super) fn add_remote_segments( + cg: &CommitGraph, + store: &mut SegmentData, + sidx_of_tip: &IdMap, + in_set: &IdSet, + owner_of: &IdMap, + symbolic_remotes: &[String], + stack_branches: Option<&[Vec]>, + region_pinned: &IdSet, + remote_tracking: &HashMap, + plan: &ChainPlan, + claimed_remote_names: &HashSet, + pending_edges: &mut Vec<(usize, gix::ObjectId)>, +) { + let mut locals: Vec<(usize, gix::refs::FullName)> = sidx_of_tip + .iter() + .filter_map(|(&tip, &sidx)| { + let name = plan.base_name_of.get(&tip)?; + let rt = remote_tracking.get(name).cloned()?; + Some((store.sidx_by_ref(name).unwrap_or(sidx), rt)) + }) + .collect(); + locals.sort_by_key(|&(sidx, ..)| sidx); + for (link_sidx, remote_ref) in locals { + let Some(remote_tip) = cg.commit_by_ref(remote_ref.as_ref()) else { + continue; + }; + if in_set.contains(&remote_tip) { + let owner = owner_of.get(&remote_tip).copied().unwrap_or(remote_tip); + let owner_sidx = sidx_of_tip[&owner]; + let named_by_this = plan + .renames + .get(&owner) + .is_some_and(|(name, _)| name == &remote_ref); + if named_by_this { + store.segments[owner_sidx].sibling_segment_id = Some(link_sidx); + store.segments[link_sidx].remote_tracking_branch_segment_id = Some(owner_sidx); + } else { + let remote_sidx = store.add_segment(Some(remote_ref.clone()), Vec::new()); + store.set_tip(remote_sidx, remote_tip); + store.segments[remote_sidx].sibling_segment_id = Some(link_sidx); + store.segments[link_sidx].remote_tracking_branch_segment_id = Some(remote_sidx); + store.connect(remote_sidx, owner_sidx); + } + continue; + } + let in_play = remote_name_in_play(&remote_ref, symbolic_remotes); + if !in_play || super::is_stack_branch(stack_branches, &remote_ref) { + continue; + } + segment_ahead_region( + cg, + store, + Some(&remote_ref), + remote_tip, + in_set, + sidx_of_tip, + owner_of, + remote_tracking, + Some(link_sidx), + region_pinned, + claimed_remote_names, + pending_edges, + ); + } +} + +/// Segment one AHEAD region into segments: the [`AheadRegion`] shape, the interior cut/stop scan, +/// then segments and edges in creation order. +#[allow(clippy::too_many_arguments)] +pub(super) fn segment_ahead_region( + cg: &CommitGraph, + store: &mut SegmentData, + remote_ref: Option<&gix::refs::FullName>, + remote_tip: gix::ObjectId, + in_set: &IdSet, + sidx_of_tip: &IdMap, + owner_of: &IdMap, + remote_tracking: &HashMap, + local_sidx: Option, + pinned_commits: &IdSet, + claimed_remote_names: &HashSet, + pending_edges: &mut Vec<(usize, gix::ObjectId)>, +) { + let region = AheadRegion::compute(cg, remote_tip, in_set); + let ahead_set = ®ion.set; + let is_boundary = + |c: gix::ObjectId| region.is_shape_boundary(cg, remote_tip, pinned_commits, c); + // Merge-heavy regions make nearly every commit a tip, and a per-tip store scan is + // quadratic (20s on an 80k-commit repo) โ€” index first commits up front instead. + let mut first_commit_sidx: IdMap = IdMap::default(); + let mut remote_first_commits: IdSet = IdSet::default(); + for (sidx, seg) in store.segments.iter().enumerate() { + if let Some(&first) = seg.commits.first() { + first_commit_sidx.entry(cg.id_at(first)).or_insert(sidx); + if store.is_remote_segment(sidx) { + remote_first_commits.insert(cg.id_at(first)); + } + } + } + + let root_is_remote = + remote_ref.is_some_and(|r| r.as_ref().category() == Some(Category::RemoteBranch)); + let mut interior_cuts: IdMap = IdMap::default(); + let mut stop: Option = None; + if root_is_remote { + let existing_remote_tip = |c: gix::ObjectId| remote_first_commits.contains(&c); + let mut id = cg + .first_parent(remote_tip) + .filter(|p| ahead_set.contains(p)); + while let Some(c) = id { + if is_boundary(c) { + break; + } + if cg + .refs_at(c) + .iter() + .any(|r| claimed_remote_names.contains(r)) + || existing_remote_tip(c) + { + stop = Some(c); + break; + } + if let Some(r) = cg.refs_at(c).into_iter().find(|r| { + r.as_ref().category() == Some(Category::RemoteBranch) + && !claimed_remote_names.contains(r) + && store.sidx_by_ref(r).is_none() + }) { + interior_cuts.insert(c, r); + } + id = cg.first_parent(c).filter(|p| ahead_set.contains(p)); + } + } + let is_boundary = + |c: gix::ObjectId| is_boundary(c) || interior_cuts.contains_key(&c) || stop == Some(c); + + let tips = region_tips(cg, ®ion, remote_tip, &is_boundary); + let mut ahead_owner: IdMap = IdMap::default(); + let mut ahead_sidx: IdMap = IdMap::default(); + let mut reused: IdSet = IdSet::default(); + for &tip in &tips { + if stop == Some(tip) { + continue; + } + let commits = commit_run(cg, tip, ahead_set, &is_boundary, |_| {}); + for &c in &commits { + ahead_owner.insert(cg.id_at(c), tip); + } + let is_root = tip == remote_tip; + if !is_root && let Some(&existing) = first_commit_sidx.get(&tip) { + ahead_sidx.insert(tip, existing); + reused.insert(tip); + continue; + } + // Like the interior cuts: a name that already names a segment (e.g. an + // advanced-outside branch over these same commits) is taken โ€” the run stays + // anonymous rather than authoring the name twice. + let name = (if is_root { + remote_ref + .cloned() + .or_else(|| unique_plain_local(cg, remote_tip)) + } else { + interior_cuts + .get(&tip) + .cloned() + .or_else(|| unique_plain_local(cg, tip)) + }) + .filter(|name| store.sidx_by_ref(name).is_none()); + let sidx = store.add_segment(name, commits); + if let Some(name) = store.segments[sidx].ref_name().map(|n| n.to_owned()) { + store.set_tip(sidx, if is_root { remote_tip } else { tip }); + if is_plain_local_branch(&name) { + store.segments[sidx].remote_tracking_ref_name = remote_tracking.get(&name).cloned(); + } + } + if is_root { + store.segments[sidx].sibling_segment_id = local_sidx; + if let Some(local_sidx) = local_sidx { + store.segments[local_sidx].remote_tracking_branch_segment_id = Some(sidx); + } + } + if let Some(cut_ref) = interior_cuts.get(&tip) { + let cut_ref = cut_ref.clone(); + store.link_remote_to_local(sidx, &cut_ref, remote_tracking); + } + if let Some(&first) = store.segments[sidx].commits.first() { + first_commit_sidx.entry(cg.id_at(first)).or_insert(sidx); + } + ahead_sidx.insert(tip, sidx); + } + + for &tip in &tips { + if reused.contains(&tip) || stop == Some(tip) { + continue; + } + let src = ahead_sidx[&tip]; + let bottom = store.segments[src] + .commits + .last() + .map(|&h| cg.id_at(h)) + .unwrap_or(tip); + for parent in cg.all_parent_ids(bottom) { + let dst = if ahead_set.contains(&parent) { + ahead_owner + .get(&parent) + .and_then(|o| ahead_sidx.get(o)) + .copied() + } else { + owner_of + .get(&parent) + .and_then(|o| sidx_of_tip.get(o)) + .copied() + }; + if let Some(dst) = dst { + store.connect(src, dst); + } else if ahead_set.contains(&parent) { + pending_edges.push((src, parent)); + } + } + } +} + +/// Unclaimed remote refs whose local counterpart shares the tip become empty roots into the +/// owning segment (in-set case only). +#[tracing::instrument(level = "trace", skip_all)] +pub(super) fn add_untracked_remote_segments( + cg: &CommitGraph, + store: &mut SegmentData, + remote_tracking: &HashMap, + sidx_of_tip: &IdMap, + in_set: &IdSet, + owner_of: &IdMap, +) { + for r in super::remote_refs(cg) { + if store.sidx_by_ref(&r).is_some() { + continue; + } + let Some(tip) = cg.commit_by_ref(r.as_ref()) else { + continue; + }; + let has_local_counterpart = cg + .refs_at(tip) + .iter() + .any(|l| remote_tracking.get(l) == Some(&r)); + if !has_local_counterpart { + continue; + } + if in_set.contains(&tip) + && let Some(&owner) = owner_of.get(&tip) + && let Some(&owner_sidx) = sidx_of_tip.get(&owner) + { + let remote_sidx = store.add_segment(Some(r.clone()), Vec::new()); + store.set_tip(remote_sidx, tip); + store.connect(remote_sidx, owner_sidx); + store.link_remote_to_local(remote_sidx, &r, remote_tracking); + } + } +} + +/// Surface the target remote: the in-set case adds the sibling link when the plan's rename +/// already named the owner; an outside target grows its region, and a local tracking branch +/// on the region's tip takes the name โ€” the remote becomes an empty segment above it, +/// sibling-linked. +#[allow(clippy::too_many_arguments)] +#[tracing::instrument(level = "trace", skip_all)] +pub(super) fn surface_target_remote( + cg: &CommitGraph, + store: &mut SegmentData, + target_ref: Option<&gix::refs::FullName>, + in_set: &IdSet, + sidx_of_tip: &IdMap, + owner_of: &IdMap, + plan: &ChainPlan, + remote_tracking: &HashMap, + region_pinned: &IdSet, + claimed_remote_names: &HashSet, + pending_edges: &mut Vec<(usize, gix::ObjectId)>, +) { + let Some(tr) = target_ref else { return }; + if tr.as_ref().category() != Some(Category::RemoteBranch) { + return; + } + let Some(tip) = cg.commit_by_ref(tr.as_ref()) else { + return; + }; + if in_set.contains(&tip) { + let owner_tip = owner_of.get(&tip).copied().unwrap_or(tip); + if plan.renames.get(&owner_tip).is_some_and(|(n, _)| n == tr) + && let Some(owner_sidx) = store.sidx_by_commit(cg, tip) + { + // Sibling: the segment whose FIRST commit is the local tracking ref's position. + let local_sidx = remote_tracking + .iter() + .find(|(_, r)| *r == tr) + .and_then(|(local, _)| cg.commit_by_ref(local.as_ref())) + .and_then(|lc| { + store.sidx_by_commit(cg, lc).filter(|&sidx| { + sidx != owner_sidx + && store.segments[sidx] + .commits + .first() + .is_some_and(|&h| cg.id_at(h) == lc) + }) + }); + if let Some(local_sidx) = local_sidx { + store.segments[owner_sidx].sibling_segment_id = Some(local_sidx); + } + } + return; + } + if store.sidx_by_ref(tr).is_some() { + return; + } + segment_ahead_region( + cg, + store, + Some(tr), + tip, + in_set, + sidx_of_tip, + owner_of, + remote_tracking, + None, + region_pinned, + claimed_remote_names, + pending_edges, + ); + let local_on_tip = remote_tracking + .iter() + .find(|(local, r)| *r == tr && cg.commit_by_ref(local.as_ref()) == Some(tip)) + .map(|(local, _)| local.clone()); + if let Some(local) = local_on_tip + && let Some(owner) = store.sidx_by_commit(cg, tip) + && store.segments[owner].ref_name() == Some(tr.as_ref()) + && store.segments[owner] + .commits + .first() + .is_some_and(|&h| cg.id_at(h) == tip) + { + store.set_name(owner, local, Some(tip)); + store.segments[owner].remote_tracking_ref_name = Some(tr.clone()); + let remote_sidx = store.add_segment(Some(tr.clone()), Vec::new()); + store.set_tip(remote_sidx, tip); + store.segments[remote_sidx].sibling_segment_id = Some(owner); + store.segments[owner].remote_tracking_branch_segment_id = Some(remote_sidx); + store.connect(remote_sidx, owner); + } +} + +/// Every further remote ref on a remote segment's first commit becomes an empty segment pointing at +/// it. +#[tracing::instrument(level = "trace", skip_all)] +pub(super) fn add_co_located_remote_empties( + cg: &CommitGraph, + store: &mut SegmentData, + remote_tracking: &HashMap, +) { + let existing = store.segments.len(); + for sidx in 0..existing { + if !store.is_remote_segment(sidx) { + continue; + } + let Some(&first) = store.segments[sidx].commits.first() else { + continue; + }; + for ri in cg.node_at(first).refs.clone().iter() { + if ri.ref_name.as_ref().category() != Some(Category::RemoteBranch) + || store.sidx_by_ref(&ri.ref_name).is_some() + { + continue; + } + let empty = store.add_segment(Some(ri.ref_name.clone()), Vec::new()); + store.set_tip(empty, cg.id_at(first)); + store.connect(empty, sidx); + store.link_remote_to_local(empty, &ri.ref_name, remote_tracking); + } + } +} diff --git a/crates/but-graph/src/build/remotes.rs b/crates/but-graph/src/build/remotes.rs new file mode 100644 index 00000000000..e1f6ee4c1fc --- /dev/null +++ b/crates/but-graph/src/build/remotes.rs @@ -0,0 +1,207 @@ +//! Remote-region decisions: the AHEAD-region shape, its segment tips, and which +//! remote names are in play. + +use std::collections::HashMap; + +use super::{IdMap, IdSet, is_plain_local_branch}; +use crate::CommitGraph; + +/// A region's AHEAD set (commits reachable from its tip that are not in-set) with the shape +/// data it is segmented by: merges' first parents and the child fan-out. +pub(super) struct AheadRegion { + pub(super) set: IdSet, + merge_first_parents: IdSet, + children: IdMap>, +} + +impl AheadRegion { + pub(super) fn compute(cg: &CommitGraph, tip: gix::ObjectId, in_set: &IdSet) -> Self { + let mut set = IdSet::default(); + let mut stack = vec![tip]; + while let Some(id) = stack.pop() { + if in_set.contains(&id) || !set.insert(id) { + continue; + } + stack.extend(cg.all_parent_ids(id)); + } + let mut children: IdMap> = IdMap::default(); + for &c in &set { + for p in cg.all_parent_ids(c) { + if set.contains(&p) { + children.entry(p).or_default().push(c); + } + } + } + let merge_first_parents: IdSet = set + .iter() + .filter(|&&c| cg.all_parent_ids(c).len() > 1) + .filter_map(|&c| cg.first_parent(c)) + .filter(|p| set.contains(p)) + .collect(); + AheadRegion { + set, + merge_first_parents, + children, + } + } + + /// The region's SHAPE boundaries, mirroring local segmentation: the tip, pinned commits, + /// merges and their first parents, plain-local-branch carriers, and fan-out/second-parent + /// joints. + pub(super) fn is_shape_boundary( + &self, + cg: &CommitGraph, + tip: gix::ObjectId, + pinned_commits: &IdSet, + c: gix::ObjectId, + ) -> bool { + c == tip + || pinned_commits.contains(&c) + || cg.all_parent_ids(c).len() > 1 + || self.merge_first_parents.contains(&c) + || cg.refs_at(c).iter().any(is_plain_local_branch) + || { + let kids = self.children.get(&c).map(Vec::as_slice).unwrap_or_default(); + kids.len() > 1 + || kids + .iter() + .any(|&k| cg.first_parent(k) != Some(c) && self.set.contains(&k)) + } + } +} + +/// The region's segment tips in minting order: the region tip first, then descending +/// generation, then id โ€” deterministic even though the ahead set is a hash set. +pub(super) fn region_tips( + cg: &CommitGraph, + region: &AheadRegion, + region_tip: gix::ObjectId, + is_boundary: &impl Fn(gix::ObjectId) -> bool, +) -> Vec { + let mut tips: Vec = region + .set + .iter() + .copied() + .filter(|&c| is_boundary(c)) + .collect(); + tips.sort_by_cached_key(|&t| { + ( + t != region_tip, + std::cmp::Reverse(cg.generation_of(t).unwrap_or(0)), + t, + ) + }); + tips +} + +/// The unique plain local branch at `c`, if any โ€” the name fallback for region roots and +/// interior boundaries; ambiguity yields `None`. +pub(super) fn unique_plain_local( + cg: &CommitGraph, + c: gix::ObjectId, +) -> Option { + let mut it = cg.refs_at(c).into_iter().filter(is_plain_local_branch); + it.next().filter(|_| it.next().is_none()) +} + +/// Is `remote_ref` on a remote the workspace configuration implies (target/push remote, or a +/// git-configured tracking branch)? Only such remotes' ahead regions are traversed. +pub(super) fn remote_name_in_play( + remote_ref: &gix::refs::FullName, + symbolic_remotes: &[String], +) -> bool { + remote_ref + .as_bstr() + .strip_prefix(b"refs/remotes/".as_ref()) + .is_some_and(|rest| { + symbolic_remotes.iter().any(|r| { + rest.strip_prefix(r.as_bytes()) + .is_some_and(|s| s.first() == Some(&b'/')) + }) + }) +} + +/// Local branch -> its remote-tracking branch, mirroring the walk's +/// `lookup_remote_tracking_branch_or_deduce_it`, plus the SYMBOLIC remote names in play: +/// 1. A branch CONFIGURED in git (`branch..remote`/`merge`) tracks that remote branch. +/// 2. Otherwise the relationship is deduced by name (`refs/remotes//` for `refs/heads/`), +/// but ONLY against remotes the workspace configuration implies โ€” the `push_remote` (highest +/// priority: "the push-remote overrides the remote we use for listing, even if a fetch remote is +/// available"), then the remote of the configured `target_ref`. A workspace with neither deduces +/// NO name-based relationships at all. +/// +/// The returned symbolic names also gate which remotes' AHEAD regions the graph traverses โ€” a +/// config-only tracking link keeps its name, but its remote's own commits stay out of the graph, +/// matching what the walk's traversal reaches. +#[tracing::instrument(level = "trace", skip_all)] +pub(crate) fn remote_tracking_from_repository( + repo: &gix::Repository, + overlay_repo: &crate::walk::overlay::OverlayRepo<'_>, + project_meta: &but_core::ref_metadata::ProjectMeta, +) -> anyhow::Result<( + HashMap, + Vec, +)> { + let mut remotes: Vec = Vec::new(); + if let Some(push_remote) = project_meta.push_remote.as_deref() { + remotes.push(push_remote.to_string()); + } + if let Some(target_ref) = project_meta.target_ref.as_ref() + && let Some((remote, _short)) = + but_core::extract_remote_name_and_short_name(target_ref.as_ref(), &repo.remote_names()) + && !remotes.contains(&remote) + { + remotes.push(remote); + } + + // Only the remotes namespace, through the per-build scan cache โ€” the walker's ref + // mapping consumes the same expensive iteration. + let remote_refs = overlay_repo.raw_refs_prefixed("refs/remotes/")?; + let mut map = HashMap::new(); + // Name-deduction against the symbolic remotes. + for remote in &remotes { + let prefix = format!("refs/remotes/{remote}/"); + for (_id, name) in remote_refs.iter() { + if let Some(short) = name.as_bstr().strip_prefix(prefix.as_bytes()) { + let local = format!("refs/heads/{}", String::from_utf8_lossy(short)); + if let Ok(local_ref) = gix::refs::FullName::try_from(local) { + // The first (highest-priority) remote to claim a local branch wins. + map.entry(local_ref).or_insert_with(|| name.clone()); + } + } + } + } + // Git-configured tracking branches win over name-deduction. + let mut config_bound: HashMap = HashMap::new(); + for reference in repo.references()?.local_branches()?.filter_map(Result::ok) { + let local = reference.name().to_owned(); + // The configured NAME counts even when the remote ref does not exist (yet) โ€” the link is + // name-only then, and passes that need the remote's commits skip unresolvable refs anyway. + if let Some(Ok(rt)) = + repo.branch_remote_tracking_ref_name(local.as_ref(), gix::remote::Direction::Fetch) + { + let rt = rt.into_owned(); + // The walk also traverses the remotes of git-configured tracking branches โ€” their remote + // names join the eligibility set. Read the configured name (never split the tracking + // ref at a slash โ€” remote names may contain slashes). + if let Some(remote) = repo + .branch_remote_name(local.shorten(), gix::remote::Direction::Fetch) + .and_then(|name| name.as_symbol().map(ToOwned::to_owned)) + && !remotes.contains(&remote) + { + remotes.push(remote); + } + config_bound.insert(rt.clone(), local.clone()); + map.insert(local, rt); + } + } + // A remote tracks ONE local: a git-CONFIGURED binding evicts a name-deduced pair for the same + // remote (e.g. `base-of-A` configured to track `origin/A` after `A` was rebased away from it โ€” + // `A` no longer tracks anything). + map.retain(|local, rt| { + config_bound + .get(rt) + .is_none_or(|config_local| config_local == local) + }); + Ok((map, remotes)) +} diff --git a/crates/but-graph/src/build/segment_data.rs b/crates/but-graph/src/build/segment_data.rs new file mode 100644 index 00000000000..cfff083d5f3 --- /dev/null +++ b/crates/but-graph/src/build/segment_data.rs @@ -0,0 +1,1059 @@ +//! The build authors [`Segment`]s directly: they are minted from the plan data alone +//! (allocation order, connection order), and the stored ref positions derive from them +//! ([`derive_ref_layout`](super::layout::derive_ref_layout)). The rows +//! never leave the build โ€” position `i` is id `i`. + +use std::collections::{BTreeMap, HashMap, HashSet}; + +use gix::reference::Category; + +use super::materialize::AdvancedOutside; +use super::materialize::tip_run_and_name; +use super::plan::ChainPlan; +use super::plan::{GroupPlacement, LayoutPlan, RefChain}; +use super::remote_segments::{ + add_co_located_remote_empties, add_remote_segments, add_untracked_remote_segments, + segment_ahead_region, surface_target_remote, +}; +use super::{IdMap, IdSet}; +use crate::CommitGraph; + +/// One authored row: a name, the commit run it owns and its ordered outgoing edges (both +/// in arena/row indices), plus the remote links enrichment reads. +#[derive(Debug, Default)] +pub(super) struct Segment { + /// The row's (disambiguated) name, if any. + pub name: Option, + /// The commit the name resolves to โ€” `None` for synthetic (metadata-derived) empties, + /// whose name has no resolved ref tip. + pub tip: Option, + /// The name of this row's remote tracking branch, if present. + pub remote_tracking_ref_name: Option, + /// Doubly-links remote and local tracking rows; also points an anonymous ancestor at + /// the workspace-known named row it stands in for. + pub sibling_segment_id: Option, + /// The row of `remote_tracking_ref_name`, when that is set. + pub remote_tracking_branch_segment_id: Option, + /// The commit run this row owns, as handles into the arena. + pub commits: Vec, + /// Outgoing edges to other rows, in first-parent order. Edge semantics derive from + /// the rows' commits: the source's last connects to the target's first. + pub connections: Vec, +} + +impl Segment { + /// The name as a ref, for comparisons. + pub fn ref_name(&self) -> Option<&gix::refs::FullNameRef> { + self.name.as_ref().map(|n| n.as_ref()) + } +} + +/// The row arena the build authors: indices are allocation-ordered and no pass removes +/// entries, so a row's position IS its id. +#[derive(Default)] +pub(super) struct SegmentData { + pub segments: Vec, + /// Rows by CURRENT name โ€” [`Self::sidx_by_ref`] answers from here in O(1) where a scan + /// over all rows pays per lookup at scale. Every name write flows through + /// [`Self::add_segment`], [`Self::set_name`], [`Self::clear_name`], and + /// [`Self::take_name`]/[`Self::put_name`], which keep it exact; duplicate names order + /// by row id, matching the scan's first-match. + by_name: HashMap>, + /// Passive refs the BUILD adds onto a commit (by arena handle) beyond what the arena + /// carries โ€” a float's displaced name. The position derivation merges them in. + pub extra_refs: HashMap>, + /// The entrypoint's segment โ€” the root of the layout's reach computation, and its name marks + /// the HEAD ordinals. + pub entrypoint_sidx: Option, + /// The commit the entrypoint rests on, `None` when unborn. + pub entrypoint: Option, +} + +impl SegmentData { + /// The commit the entrypoint UNAMBIGUOUSLY points to, resolved through its empty + /// chain: an empty segment resolves along its only connection; a fork or dead end + /// yields `None`. The verdict the projection reads off the context. + pub(super) fn resolve_entrypoint_commit(&self, cg: &CommitGraph) -> Option { + let mut current = self.entrypoint_sidx?; + let mut seen = HashSet::new(); + while seen.insert(current) { + if let Some(&commit) = self.segments[current].commits.first() { + return Some(cg.id_at(commit)); + } + let &[only] = self.segments[current].connections.as_slice() else { + return None; + }; + current = only; + } + None + } + + pub(super) fn add_segment( + &mut self, + name: Option, + commits: Vec, + ) -> usize { + let id = self.segments.len(); + if let Some(name) = &name { + self.by_name.entry(name.clone()).or_default().insert(id); + } + self.segments.push(Segment { + name, + commits, + ..Default::default() + }); + id + } + + fn unindex_name(&mut self, sidx: usize) { + if let Some(name) = &self.segments[sidx].name + && let Some(rows) = self.by_name.get_mut(name) + { + rows.remove(&sidx); + if rows.is_empty() { + self.by_name.remove(name); + } + } + } + + /// Detach `segment`'s name (index included), returning it. Everything else stays. + pub(super) fn take_name(&mut self, sidx: usize) -> Option { + self.unindex_name(sidx); + self.segments[sidx].name.take() + } + + /// Attach `name` (if any) to `segment` (index included). Everything else stays. + pub(super) fn put_name(&mut self, sidx: usize, name: Option) { + self.unindex_name(sidx); + if let Some(name) = &name { + self.by_name.entry(name.clone()).or_default().insert(sidx); + } + self.segments[sidx].name = name; + } + + /// Anonymize `segment`: name and tip cleared. + pub(super) fn clear_name(&mut self, sidx: usize) { + self.unindex_name(sidx); + self.segments[sidx].name = None; + self.segments[sidx].tip = None; + } + + /// Record the commit `segment`'s name resolves to. A no-op on nameless segments. + pub(super) fn set_tip(&mut self, sidx: usize, id: gix::ObjectId) { + if self.segments[sidx].name.is_some() { + self.segments[sidx].tip = Some(id); + } + } + + /// Name (or rename) `segment`, with the tip its name resolves to. + pub(super) fn set_name( + &mut self, + sidx: usize, + ref_name: gix::refs::FullName, + commit_id: Option, + ) { + self.put_name(sidx, Some(ref_name)); + self.segments[sidx].tip = commit_id; + } + + /// Link a just-created remote-named segment to the local segment named by its tracking counterpart: + /// the remote's sibling points at the local, and the local carries the remote's name and + /// segment id. A no-op when no such local exists. + pub(super) fn link_remote_to_local( + &mut self, + remote_sidx: usize, + remote_ref: &gix::refs::FullName, + remote_tracking: &HashMap, + ) { + let Some(local_name) = remote_tracking + .iter() + .find_map(|(l, r)| (r == remote_ref).then_some(l)) + else { + return; + }; + let Some(local_sidx) = self.sidx_by_ref(local_name) else { + return; + }; + self.segments[remote_sidx].sibling_segment_id = Some(local_sidx); + self.segments[local_sidx].remote_tracking_ref_name = Some(remote_ref.clone()); + self.segments[local_sidx].remote_tracking_branch_segment_id = Some(remote_sidx); + } + + /// Append `src` โ†’ `dst`. + pub(super) fn connect(&mut self, src: usize, dst: usize) { + self.segments[src].connections.push(dst); + } + + /// Insert `src` โ†’ `dst` at `parent number` among `src`'s edges (clamped). + fn insert_connect_at(&mut self, src: usize, parent_number: usize, dst: usize) { + let edges = &mut self.segments[src].connections; + let parent_number = parent_number.min(edges.len()); + edges.insert(parent_number, dst); + } + + /// Re-point `src`'s edges at `old_target` to `new_target`. + pub(super) fn retarget_edges( + &mut self, + src: usize, + old_target: usize, + new_target: usize, + ) -> usize { + let mut retargeted = 0; + for target in &mut self.segments[src].connections { + if *target == old_target { + *target = new_target; + retargeted += 1; + } + } + retargeted + } + + pub(super) fn sidx_by_commit(&self, cg: &CommitGraph, commit: gix::ObjectId) -> Option { + self.sidx_by_commit_excluding(cg, commit, &HashSet::new()) + } + + /// Like [`Self::sidx_by_commit`] but ignoring `exclude`d segments โ€” the pre-chain coverage view. + fn sidx_by_commit_excluding( + &self, + cg: &CommitGraph, + commit: gix::ObjectId, + exclude: &HashSet, + ) -> Option { + (0..self.segments.len()).find(|&sidx| { + !exclude.contains(&sidx) + && self.segments[sidx] + .commits + .iter() + .any(|&h| cg.id_at(h) == commit) + }) + } + + /// The first segment (in id order) named `name`. + pub(super) fn sidx_by_ref(&self, name: &gix::refs::FullName) -> Option { + self.by_name + .get(name) + .and_then(|rows| rows.first().copied()) + } + + pub(super) fn is_remote_segment(&self, sidx: usize) -> bool { + self.segments[sidx] + .ref_name() + .is_some_and(|name| name.category() == Some(Category::RemoteBranch)) + } + + /// Every segment's outgoing edges as target ordinals in FINAL parent order: real parents by + /// their index in the source commit's parent array, commit-less edges after them in edge + /// order, ordinals compacted by push order. + pub(super) fn parent_ordered_targets(&self, cg: &CommitGraph) -> OrderedTargets { + let mut start = Vec::with_capacity(self.segments.len() + 1); + let mut flat = Vec::new(); + let mut ordered: Vec<(usize, usize)> = Vec::new(); + start.push(0); + for sidx in &self.segments { + let mut empty_branch_count = 0usize; + ordered.clear(); + // An edge's endpoints derive from the segments' commits: the source's LAST + // commit connects to the target's FIRST (a stored edge record would only + // duplicate this). + let edge_parents = sidx + .commits + .last() + .map(|&h| cg.node_at(h).parent_ids.as_slice()); + for &target in &sidx.connections { + let dst_id = self.segments[target].commits.first().map(|&h| cg.id_at(h)); + let real_parent_index = edge_parents + .zip(dst_id) + .and_then(|(parents, dst)| parents.iter().position(|p| *p == dst)); + let ordinal = match real_parent_index { + Some(idx) => idx, + None => { + let o = edge_parents.map_or(0, |p| p.len()) + empty_branch_count; + empty_branch_count += 1; + o + } + }; + ordered.push((ordinal, target)); + } + ordered.sort_by_key(|(ordinal, _)| *ordinal); + flat.extend(ordered.iter().map(|&(_, t)| t)); + start.push(flat.len()); + } + OrderedTargets { start, flat } + } +} + +/// [`SegmentData::parent_ordered_targets`] in CSR form: one flat target list, segment-sliced. +pub(super) struct OrderedTargets { + /// Segment `s`'s targets are `flat[start[s]..start[s + 1]]`. + start: Vec, + flat: Vec, +} + +impl OrderedTargets { + pub(super) fn of(&self, sidx: usize) -> &[usize] { + &self.flat[self.start[sidx]..self.start[sidx + 1]] + } +} + +/// Everything the build reads: the commit graph plus the decided data (facts fields, the +/// plan, the layout, and the chain-structure decisions). +pub(super) struct GraphInputs<'a> { + pub cg: &'a CommitGraph, + pub tips: &'a [gix::ObjectId], + pub in_set: &'a IdSet, + pub boundaries: &'a IdSet, + pub owner_of: &'a IdMap, + pub plan: &'a ChainPlan, + pub layout: &'a LayoutPlan, + pub workspace_commit: gix::ObjectId, + pub ws_empty_ref: Option<&'a gix::refs::FullName>, + pub advanced_outside: &'a [AdvancedOutside], + pub remote_tracking: &'a HashMap, + pub symbolic_remotes: &'a [String], + pub stack_branches: Option<&'a [Vec]>, + /// Index into `layout.chains` where the AD-HOC chains begin: they splice only after + /// the entry region exists (managed builds mint that region late). + pub ad_hoc_chain_start: usize, + pub region_pinned: &'a IdSet, + pub claimed_remote_names: &'a HashSet, + pub entrypoint: gix::ObjectId, + pub entrypoint_ref: Option<&'a gix::refs::FullName>, + pub target_ref: Option<&'a gix::refs::FullName>, + pub extra_target: Option, +} + +/// THE BUILD: every materializer pass (mint + connect + chain structure + the remote passes + +/// the coverage regions + the sweeps) run on the store from the decisions alone. The store +/// authors the stored ref positions; it never becomes graph storage itself. +#[tracing::instrument(name = "segment_data::build", level = "trace", skip_all)] +pub(super) fn build(inputs: GraphInputs<'_>) -> SegmentData { + let (mut store, sidx_of_tip) = mint_segments( + inputs.cg, + inputs.tips, + inputs.in_set, + inputs.boundaries, + inputs.plan, + inputs.workspace_commit, + inputs.remote_tracking, + ); + let before_chains = store.segments.len(); + if inputs.stack_branches.is_some() || !inputs.layout.chains.is_empty() { + build_chain_structure( + inputs.cg, + &mut store, + &sidx_of_tip, + inputs.workspace_commit, + inputs.ws_empty_ref, + inputs.advanced_outside, + inputs.layout, + inputs.ad_hoc_chain_start, + inputs.remote_tracking, + ); + } + // The coverage gates evaluate the PRE-CHAIN view: segments minted by the chain-structure + // pass don't count as coverage. + let chain_created: HashSet = (before_chains..store.segments.len()).collect(); + let mut pending_edges: Vec<(usize, gix::ObjectId)> = Vec::new(); + add_remote_segments( + inputs.cg, + &mut store, + &sidx_of_tip, + inputs.in_set, + inputs.owner_of, + inputs.symbolic_remotes, + inputs.stack_branches, + inputs.region_pinned, + inputs.remote_tracking, + inputs.plan, + inputs.claimed_remote_names, + &mut pending_edges, + ); + add_untracked_remote_segments( + inputs.cg, + &mut store, + inputs.remote_tracking, + &sidx_of_tip, + inputs.in_set, + inputs.owner_of, + ); + surface_target_remote( + inputs.cg, + &mut store, + inputs.target_ref, + inputs.in_set, + &sidx_of_tip, + inputs.owner_of, + inputs.plan, + inputs.remote_tracking, + inputs.region_pinned, + inputs.claimed_remote_names, + &mut pending_edges, + ); + // The extra-target twin: a stored target position uncovered by any pre-chain segment grows + // its own (nameless) region. + if let Some(extra) = inputs.extra_target + && inputs.cg.node(extra).is_some() + && store + .sidx_by_commit_excluding(inputs.cg, extra, &chain_created) + .is_none() + { + segment_ahead_region( + inputs.cg, + &mut store, + None, + extra, + inputs.in_set, + &sidx_of_tip, + inputs.owner_of, + inputs.remote_tracking, + None, + inputs.region_pinned, + inputs.claimed_remote_names, + &mut pending_edges, + ); + } + // The outside-entrypoint twin: an adhoc checkout outside the workspace grows its region. + if !inputs.in_set.contains(&inputs.entrypoint) + && inputs.cg.node(inputs.entrypoint).is_some() + && store + .sidx_by_commit_excluding(inputs.cg, inputs.entrypoint, &chain_created) + .is_none() + { + segment_ahead_region( + inputs.cg, + &mut store, + inputs.entrypoint_ref, + inputs.entrypoint, + inputs.in_set, + &sidx_of_tip, + inputs.owner_of, + inputs.remote_tracking, + None, + inputs.region_pinned, + inputs.claimed_remote_names, + &mut pending_edges, + ); + } + cover_explicit_seeds( + inputs.cg, + &mut store, + &chain_created, + inputs.in_set, + &sidx_of_tip, + inputs.owner_of, + inputs.remote_tracking, + inputs.region_pinned, + inputs.claimed_remote_names, + &mut pending_edges, + ); + // The target's remote segment may exist before its LOCAL got a segment (the local can materialize + // from the extra-target region above) โ€” link them like every other creator does. + if let Some(tr) = inputs.target_ref + && let Some(tr_sidx) = store.sidx_by_ref(tr) + { + store.link_remote_to_local(tr_sidx, tr, inputs.remote_tracking); + } + add_co_located_remote_empties(inputs.cg, &mut store, inputs.remote_tracking); + wire_pending_edges(inputs.cg, &mut store, pending_edges); + // The AD-HOC chains splice now: their anchors live in the entry region, which only + // exists from this point on (managed builds mint it after the chain structure). + if inputs.ad_hoc_chain_start < inputs.layout.chains.len() { + insert_empty_branches( + inputs.cg, + &mut store, + None, + &inputs.layout.chains[inputs.ad_hoc_chain_start..], + inputs.layout, + inputs.remote_tracking, + ); + } + if let Some(ep_sidx) = decide_remote_name_float( + inputs.cg, + &store, + inputs.entrypoint, + inputs.entrypoint_ref, + inputs.workspace_commit, + ) { + apply_remote_name_float(&mut store, ep_sidx); + } + if inputs.stack_branches.is_some() { + drop_suppressed_tip_links(&mut store, inputs.plan, &sidx_of_tip); + } + store.entrypoint = Some(inputs.entrypoint); + store.entrypoint_sidx = decide_entrypoint_row( + inputs.cg, + &store, + inputs.ws_empty_ref, + inputs.entrypoint, + inputs.entrypoint_ref, + inputs.workspace_commit, + ) + .map(|placement| { + apply_entrypoint_placement( + &mut store, + placement, + inputs.entrypoint, + inputs.entrypoint_ref, + inputs.remote_tracking, + ) + }); + store +} + +/// Tip segments in facts order, float placeholders, then the parent edges โ€” every decision read +/// from the plan data. +#[allow(clippy::too_many_arguments)] +#[tracing::instrument(level = "trace", skip_all)] +fn mint_segments( + cg: &CommitGraph, + tips: &[gix::ObjectId], + in_set: &IdSet, + boundaries: &IdSet, + plan: &ChainPlan, + workspace_commit: gix::ObjectId, + remote_tracking: &HashMap, +) -> (SegmentData, IdMap) { + let mut store = SegmentData::default(); + let mut sidx_of_tip: IdMap = IdMap::default(); + // Handle space: which segment owns each arena commit, and each run's bottom โ€” filled by the + // run walks, so the edge pass below needs no per-parent hash lookups. + let mut sidx_at: Vec = vec![u32::MAX; cg.node_count()]; + let mut bottom_at: Vec = Vec::with_capacity(tips.len()); + for (sidx, &tip) in tips.iter().enumerate() { + let mut bottom = usize::MAX; + let run = tip_run_and_name(cg, tip, in_set, boundaries, plan, |c| { + sidx_at[c] = sidx as u32; + bottom = c; + }); + bottom_at.push(bottom); + if let (Some(displaced), Some(&c0)) = (run.displaced, run.commits.first()) { + store.extra_refs.entry(c0).or_default().push(displaced); + } + let sidx = store.add_segment(run.named.as_ref().map(|nt| nt.name.clone()), run.commits); + if let Some(super::plan::NamedTip { + name, + tip: commit_id, + }) = run.named + { + store.set_tip(sidx, commit_id); + store.segments[sidx].remote_tracking_ref_name = remote_tracking.get(&name).cloned(); + } + sidx_of_tip.insert(tip, sidx); + } + let mut placeholder_of: IdMap = IdMap::default(); + for float in &plan.floats { + // Placeholders are synthetic: their name has no resolved ref tip here. + let sidx = store.add_segment(Some(float.name.clone()), Vec::new()); + store.segments[sidx].remote_tracking_ref_name = remote_tracking.get(&float.name).cloned(); + placeholder_of.insert(float.tip, sidx); + } + let float_tips: IdSet = plan.floats.iter().map(|fl| fl.tip).collect(); + for (src, &tip) in tips.iter().enumerate() { + // A tip is an in-set boundary, so its run has at least itself. + debug_assert_ne!(bottom_at[src], usize::MAX); + // One edge per in-graph parent of the run's bottom, in first-parent order; the + // WORKSPACE commit's edge to a floated parent goes to the float's placeholder. + for p in cg.connected_parents_at(bottom_at[src]) { + let r = sidx_at[p]; + if r == u32::MAX { + continue; + } + let dst = if tip == workspace_commit && float_tips.contains(&cg.id_at(p)) { + placeholder_of[&cg.id_at(p)] + } else { + r as usize + }; + store.connect(src, dst); + } + } + for float in &plan.floats { + let (Some(&ph), Some(&tip_sidx)) = + (placeholder_of.get(&float.tip), sidx_of_tip.get(&float.tip)) + else { + continue; + }; + store.connect(ph, tip_sidx); + } + (store, sidx_of_tip) +} + +/// The empty-workspace splice, the decided advanced-outside branches, then the store's +/// chains โ€” all consumed as data. +#[tracing::instrument(level = "trace", skip_all)] +#[allow(clippy::too_many_arguments)] +fn build_chain_structure( + cg: &CommitGraph, + store: &mut SegmentData, + sidx_of_tip: &IdMap, + workspace_commit: gix::ObjectId, + ws_empty_ref: Option<&gix::refs::FullName>, + advanced_outside: &[AdvancedOutside], + layout: &LayoutPlan, + ad_hoc_chain_start: usize, + remote_tracking: &HashMap, +) { + let mut ws_empty_sidx = None; + if let Some(ws_ref) = ws_empty_ref + && let Some(&stack) = sidx_of_tip.get(&workspace_commit) + { + let sidx = store.add_segment(Some(ws_ref.clone()), Vec::new()); + store.set_tip(sidx, workspace_commit); + store.connect(sidx, stack); + ws_empty_sidx = Some(sidx); + } + for decision in advanced_outside { + let Some(owner) = store.sidx_by_commit(cg, decision.rejoin) else { + continue; + }; + let sidx = store.add_segment(decision.name.clone(), decision.commits.clone()); + if let Some(name) = decision.name.as_ref() { + store.set_tip(sidx, decision.tip); + store.segments[sidx].remote_tracking_ref_name = remote_tracking.get(name).cloned(); + // Only a NAMED advanced branch is the in-workspace segment's sibling; the workspace + // position itself never links to outside content. + if decision.rejoin != workspace_commit + && store.segments[owner].sibling_segment_id.is_none() + { + store.segments[owner].sibling_segment_id = Some(sidx); + } + } + store.connect(sidx, owner); + } + let ws_sidx = ws_empty_sidx.or_else(|| sidx_of_tip.get(&workspace_commit).copied()); + insert_empty_branches( + cg, + store, + ws_sidx, + &layout.chains[..ad_hoc_chain_start.min(layout.chains.len())], + layout, + remote_tracking, + ); +} + +/// Anonymous bases lose their names, naming refs take their anchors, empties splice above in metadata +/// order. +#[tracing::instrument(level = "trace", skip_all)] +fn insert_empty_branches( + cg: &CommitGraph, + store: &mut SegmentData, + ws_sidx: Option, + chains: &[RefChain], + layout: &LayoutPlan, + remote_tracking: &HashMap, +) { + for &tip in &layout.anonymous_bases { + let Some(anchor) = store.sidx_by_commit(cg, tip) else { + continue; + }; + store.clear_name(anchor); + store.segments[anchor].remote_tracking_ref_name = None; + store.segments[anchor].remote_tracking_branch_segment_id = None; + } + for (li, chain) in chains.iter().enumerate() { + // Without a workspace anchor (the late ad-hoc pass), the chain hangs from its + // own first anchor's segment โ€” the pure ad-hoc shape. + let mut from_sidx = ws_sidx.or_else(|| { + chain + .anchors + .first() + .and_then(|&(commit, _)| store.sidx_by_commit(cg, commit)) + }); + for &(commit, gi) in &chain.anchors { + let group = &layout.at_commit[&commit][gi]; + if group.placement == GroupPlacement::Skipped { + continue; + } + let Some(anchor) = store.sidx_by_commit(cg, commit) else { + continue; + }; + if let Some(naming_ref) = &group.naming_ref { + store.set_name(anchor, naming_ref.name.clone(), Some(commit)); + store.segments[anchor].remote_tracking_ref_name = + remote_tracking.get(&naming_ref.name).cloned(); + if naming_ref.clear_remote { + store.segments[anchor].remote_tracking_branch_segment_id = None; + } + } + if let GroupPlacement::Splice { + members, + into_owning_chain, + } = &group.placement + && !members.is_empty() + { + insert_empty_chain_above( + store, + from_sidx, + anchor, + members, + remote_tracking, + *into_owning_chain, + (from_sidx == ws_sidx).then_some(li), + ); + } + from_sidx = Some(anchor); + } + } +} + +/// Splice a run of empty named segments in above `anchor`: incoming edges redirect to the top +/// empty (or a fresh edge from `from_sidx` joins it), and the bottom empty connects down. +#[allow(clippy::too_many_arguments)] +fn insert_empty_chain_above( + store: &mut SegmentData, + from_sidx: Option, + anchor: usize, + empties: &[gix::refs::FullName], + remote_tracking: &HashMap, + into_owning_chain: bool, + fresh_connection_position: Option, +) { + let ids: Vec = empties + .iter() + .map(|b| { + let sidx = store.add_segment(Some(b.clone()), Vec::new()); + store.segments[sidx].remote_tracking_ref_name = remote_tracking.get(b).cloned(); + sidx + }) + .collect(); + let Some(&top) = ids.first() else { + return; + }; + if let Some(from_sidx) = from_sidx { + let mut redirected = false; + let redirect_sources: Vec = if into_owning_chain { + (0..store.segments.len()) + .filter(|&sidx| !ids.contains(&sidx) && !store.is_remote_segment(sidx)) + .collect() + } else { + vec![from_sidx] + }; + for source in redirect_sources { + redirected |= store.retarget_edges(source, anchor, top) > 0; + } + if !redirected { + let find_parent = |require_commits: bool| { + (0..store.segments.len()).find(|&sidx| { + sidx != from_sidx + && !store.is_remote_segment(sidx) + && (!require_commits || !store.segments[sidx].commits.is_empty()) + && store.segments[sidx].connections.contains(&anchor) + }) + }; + let chain_parent = into_owning_chain + .then(|| find_parent(true).or_else(|| find_parent(false))) + .flatten(); + match chain_parent { + Some(parent) => { + store.retarget_edges(parent, anchor, top); + } + None => match fresh_connection_position { + Some(parent_number) => store.insert_connect_at(from_sidx, parent_number, top), + None => store.connect(from_sidx, top), + }, + } + } + } + for i in 0..ids.len() { + let next = ids.get(i + 1).copied().unwrap_or(anchor); + store.connect(ids[i], next); + } +} + +/// An uncovered explicit seed grows its own region; a covered one whose ref names no segment gets +/// an empty tip-named segment above its owner. +#[allow(clippy::too_many_arguments)] +#[tracing::instrument(level = "trace", skip_all)] +fn cover_explicit_seeds( + cg: &CommitGraph, + store: &mut SegmentData, + chain_created: &HashSet, + in_set: &IdSet, + sidx_of_tip: &IdMap, + owner_of: &IdMap, + remote_tracking: &HashMap, + region_pinned: &IdSet, + claimed_remote_names: &HashSet, + pending_edges: &mut Vec<(usize, gix::ObjectId)>, +) { + for t in cg.seeds.iter().filter(|_| cg.explicit_seeds) { + if cg.node(t.id).is_none() { + continue; + } + match store.sidx_by_commit_excluding(cg, t.id, chain_created) { + None => segment_ahead_region( + cg, + store, + t.ref_name.as_ref(), + t.id, + in_set, + sidx_of_tip, + owner_of, + remote_tracking, + None, + region_pinned, + claimed_remote_names, + pending_edges, + ), + Some(owner_sidx) => { + let Some(ref_name) = t.ref_name.clone() else { + continue; + }; + if but_core::is_workspace_ref_name(ref_name.as_ref()) { + continue; + } + if store.sidx_by_ref(&ref_name).is_some() + || store.segments[owner_sidx].ref_name() == Some(ref_name.as_ref()) + { + continue; + } + // The plan deliberately left this tip-started segment anonymous โ€” keep it that way. + if store.segments[owner_sidx] + .commits + .first() + .is_some_and(|&h| cg.id_at(h) == t.id) + && store.segments[owner_sidx].name.is_none() + { + continue; + } + let empty = store.add_segment(Some(ref_name.clone()), Vec::new()); + store.set_tip(empty, t.id); + store.segments[empty].remote_tracking_ref_name = + remote_tracking.get(&ref_name).cloned(); + store.connect(empty, owner_sidx); + } + } + } +} + +/// Connect each stopped segment to the segment owning its parent commit โ€” every creator has run by +/// now, so the owner exists. +#[tracing::instrument(level = "trace", skip_all)] +fn wire_pending_edges( + cg: &CommitGraph, + store: &mut SegmentData, + pending_edges: Vec<(usize, gix::ObjectId)>, +) { + for (src, parent) in pending_edges { + let Some(dst) = store.sidx_by_commit(cg, parent) else { + continue; + }; + store.connect(src, dst); + } +} + +/// Whether a ref-less checkout sits at a remote-named segment's tip โ€” decided READ-ONLY; +/// [`apply_remote_name_float`] performs the move. Returns the segment whose name floats. +fn decide_remote_name_float( + cg: &CommitGraph, + store: &SegmentData, + entrypoint: gix::ObjectId, + entrypoint_ref: Option<&gix::refs::FullName>, + workspace_commit: gix::ObjectId, +) -> Option { + if entrypoint_ref.is_some() || entrypoint == workspace_commit { + return None; + } + let ep_sidx = store.sidx_by_commit(cg, entrypoint)?; + (store.is_remote_segment(ep_sidx) + && store.segments[ep_sidx] + .commits + .first() + .map(|&h| cg.id_at(h)) + == Some(entrypoint)) + .then_some(ep_sidx) +} + +/// Apply the float: the remote name (and its links) moves to a fresh empty segment above +/// the checkout's segment; edges and links aimed at the named segment follow. +#[tracing::instrument(level = "trace", skip_all)] +fn apply_remote_name_float(store: &mut SegmentData, ep_sidx: usize) { + let name = store.take_name(ep_sidx); + let tip = store.segments[ep_sidx].tip.take(); + let rt_name = store.segments[ep_sidx].remote_tracking_ref_name.take(); + let sibling = store.segments[ep_sidx].sibling_segment_id.take(); + let rt_row = store.segments[ep_sidx] + .remote_tracking_branch_segment_id + .take(); + let floated = store.add_segment(None, Vec::new()); + store.put_name(floated, name); + store.segments[floated].tip = tip; + store.segments[floated].remote_tracking_ref_name = rt_name; + store.segments[floated].sibling_segment_id = sibling; + store.segments[floated].remote_tracking_branch_segment_id = rt_row; + for sidx in 0..store.segments.len() { + if sidx == floated { + continue; + } + if store.segments[sidx].sibling_segment_id == Some(ep_sidx) { + store.segments[sidx].sibling_segment_id = Some(floated); + } + if store.segments[sidx].remote_tracking_branch_segment_id == Some(ep_sidx) { + store.segments[sidx].remote_tracking_branch_segment_id = Some(floated); + } + store.retarget_edges(sidx, ep_sidx, floated); + } + store.connect(floated, ep_sidx); +} + +/// A floated or anonymized tip's build-time name lost its remote links to whichever segment finally +/// carries the name. +fn drop_suppressed_tip_links( + store: &mut SegmentData, + plan: &ChainPlan, + sidx_of_tip: &IdMap, +) { + for tip in plan + .floats + .iter() + .map(|fl| fl.tip) + .chain(plan.anonymous_bases.iter().copied()) + { + if let Some(&sidx) = sidx_of_tip.get(&tip) { + store.segments[sidx].remote_tracking_ref_name = None; + store.segments[sidx].remote_tracking_branch_segment_id = None; + } + } +} + +/// How the entrypoint lands in the store, decided READ-ONLY by +/// [`decide_entrypoint_row`] and applied by [`apply_entrypoint_placement`] โ€” the build's +/// last two structure changes, kept late on purpose: whether the checked-out ref ended +/// up naming a segment is knowledge no earlier pass has. +enum EntrypointPlacement { + /// An existing segment is the entry row as-is. + Existing(usize), + /// The anonymous segment starting at the entrypoint takes the checked-out ref's name. + Name(usize), + /// The entry segment is named by ANOTHER ref: an empty entrypoint-named segment + /// splices in above and becomes the row. + SpliceAbove(usize), +} + +/// Pick the entrypoint's segment โ€” the empty workspace segment for a ref-less checkout AT +/// the workspace position, the segment the checked-out ref already names, or the segment +/// STARTING at the entrypoint commit (named or split when the placement is applied). +fn decide_entrypoint_row( + cg: &CommitGraph, + store: &SegmentData, + ws_empty_ref: Option<&gix::refs::FullName>, + entrypoint: gix::ObjectId, + entrypoint_ref: Option<&gix::refs::FullName>, + workspace_commit: gix::ObjectId, +) -> Option { + if let (Some(ws_sidx), None, true) = ( + ws_empty_ref.and_then(|r| store.sidx_by_ref(r)), + entrypoint_ref, + entrypoint == workspace_commit, + ) { + return Some(EntrypointPlacement::Existing(ws_sidx)); + } + if let Some(named) = entrypoint_ref.and_then(|r| store.sidx_by_ref(r)) { + return Some(EntrypointPlacement::Existing(named)); + } + let (sidx, pos) = (0..store.segments.len()).find_map(|sidx| { + store.segments[sidx] + .commits + .iter() + .position(|&h| cg.id_at(h) == entrypoint) + .map(|p| (sidx, p)) + })?; + if pos != 0 { + return None; + } + let Some(ep_ref) = entrypoint_ref else { + return Some(EntrypointPlacement::Existing(sidx)); + }; + Some(match store.segments[sidx].ref_name() { + None => EntrypointPlacement::Name(sidx), + Some(existing) if existing != ep_ref.as_ref() => EntrypointPlacement::SpliceAbove(sidx), + Some(_) => EntrypointPlacement::Existing(sidx), + }) +} + +/// Apply the placement: name the anonymous entry segment, or splice the empty +/// entrypoint-named segment in above (which becomes the row). +fn apply_entrypoint_placement( + store: &mut SegmentData, + placement: EntrypointPlacement, + entrypoint: gix::ObjectId, + entrypoint_ref: Option<&gix::refs::FullName>, + remote_tracking: &HashMap, +) -> usize { + match placement { + EntrypointPlacement::Existing(sidx) => sidx, + EntrypointPlacement::Name(sidx) => { + let ep_ref = entrypoint_ref.expect("Name placements carry a checked-out ref"); + store.set_name(sidx, ep_ref.clone(), Some(entrypoint)); + store.segments[sidx].remote_tracking_ref_name = remote_tracking.get(ep_ref).cloned(); + sidx + } + EntrypointPlacement::SpliceAbove(sidx) => { + let ep_ref = entrypoint_ref.expect("SpliceAbove placements carry a checked-out ref"); + let empty = store.add_segment(Some(ep_ref.clone()), Vec::new()); + store.set_tip(empty, entrypoint); + store.segments[empty].remote_tracking_ref_name = remote_tracking.get(ep_ref).cloned(); + for other in 0..store.segments.len() { + if other == empty { + continue; + } + store.retarget_edges(other, sidx, empty); + } + store.connect(empty, sidx); + empty + } + } +} + +/// The final enrichments, both pure functions of a name: worktree annotation (segment names and +/// the refs riding commits) and metadata classification. Runs once the names are final. +pub(super) fn enrich( + cg: &CommitGraph, + store: &SegmentData, + meta: &T, + worktree_by_branch: &BTreeMap>, +) -> ( + std::collections::HashMap, + Option, +) { + let mut details = std::collections::HashMap::new(); + let mut workspace_meta = None; + for row in &store.segments { + let Some(name) = row.name.as_ref() else { + continue; + }; + let metadata = super::segment_metadata(name.as_ref(), meta); + if workspace_meta.is_none() + && let Some(crate::SegmentMetadata::Workspace(ws)) = &metadata + { + workspace_meta = Some(crate::workspace::WorkspaceMeta { + ref_name: name.clone(), + metadata: ws.clone(), + }); + } + let remote_walk_tip = row.remote_tracking_branch_segment_id.and_then(|rsidx| { + let remote = &store.segments[rsidx]; + remote + .commits + .first() + .map(|&h| cg.id_at(h)) + // A caught-up remote's row is empty; its ref target is the + // walk start then. + .or(remote.tip) + }); + details.insert( + name.clone(), + crate::workspace::BranchDetails { + metadata: metadata.and_then(|md| match md { + crate::SegmentMetadata::Branch(md) => Some(md), + crate::SegmentMetadata::Workspace(_) => None, + }), + worktree: worktree_by_branch + .get(name) + .and_then(|w| w.first()) + .cloned(), + remote_walk_tip, + }, + ); + } + (details, workspace_meta) +} diff --git a/crates/but-graph/src/ref_layout.rs b/crates/but-graph/src/ref_layout.rs new file mode 100644 index 00000000000..b37125e0e61 --- /dev/null +++ b/crates/but-graph/src/ref_layout.rs @@ -0,0 +1,385 @@ +//! The stored REF LAYOUT โ€” and a field guide to how this codebase talks about refs. +//! +//! The problem in one sentence: git stores refs as a flat `name โ†’ commit` list; the +//! workspace needs *structure over them* โ€” which refs form a stack together, in what +//! vertical order, and where branches with no commits of their own live. Ref management +//! declares that structure, places it onto the commit graph, and keeps it intact while +//! commits are rewritten underneath. This module holds the placed result: authored by the +//! build, stored on the [`CommitGraph`](crate::CommitGraph), read by the projection to +//! carve stacks, and ingested by the rebase editor as an id-mapping copy. +//! +//! # One example +//! +//! Two stacks, five refs: +//! +//! ```text +//! M (workspace merge commit) +//! โ•ฑ โ•ฒ +//! A2 B1 โ–บfeature-b +//! A1 โ•ฑ +//! โ•ฒ โ•ฑ +//! base โ–บmain +//! ``` +//! +//! Refs on disk: `feature-a โ†’ A2`, `review-a โ†’ A2`, `feature-b โ†’ B1`, `fresh โ†’ base`, +//! `main โ†’ base`. The flat store cannot express what a user plainly sees: that `review-a` +//! is an *empty branch stacked on* `feature-a` (two names for A2), that `fresh` is its +//! *own empty stack* (just another name for base), and that `main` is *not a stack at all*. +//! Workspace metadata declares the missing structure, one ref chain per stack: +//! `[review-a, feature-a]`, `[feature-b]`, `[fresh]`. +//! +//! # The store and the queries +//! +//! The layout stores decisions only โ€” [`RefGroup`](crate::ref_layout::RefGroup)s per commit plus per-reference +//! [`RefFacts`](crate::ref_layout::RefFacts) โ€” and *nothing derived*: +//! +//! ```text +//! on A2: RefGroup { members: [feature-a, review-a], carry: All } +//! on B1: RefGroup { members: [feature-b], carry: All } +//! ``` +//! +//! Per-reference positions are *questions*, never a second filing system: +//! [`RefLayout::positioned_on`](crate::ref_layout::RefLayout::positioned_on), [`RefLayout::below_of`](crate::ref_layout::RefLayout::below_of), [`RefLayout::facts_of`](crate::ref_layout::RefLayout::facts_of), +//! [`RefLayout::placements`](crate::ref_layout::RefLayout::placements). Think shelves vs questions at the desk: a group is a shelf +//! (the stack of refs on one commit, bottomโ†’top); "where is this ref?" is answered by +//! looking at the shelves. Nothing derived is stored, so nothing derived can go stale. +//! +//! The split serves the two consumers' opposite questions. The editor asks the *shelf* +//! question โ€” mutations move whole stacks of refs between commits โ€” so groups are the +//! stored truth, and the editor's mutation store is the *same* [`RefGroup`](crate::ref_layout::RefGroup) type over its +//! pick handles (ingest maps ids, copies the rest). The projection asks the *per-ref* +//! questions, with the same query vocabulary on both sides of that boundary. A group's +//! [`GroupCarry`](crate::ref_layout::GroupCarry) answers the one further thing flat refs can't: which of the commit's +//! incoming edges enter *through* these refs โ€” so when commits are inserted or removed +//! around A2, the editor knows those refs guard the A-side of the merge, not B's. +//! +//! # Where the commit-less chains rest +//! +//! The chain `[fresh]` has nothing to place โ€” its branches simply *rest on* an existing +//! commit. [`RefLayout::empty_chain_anchors`](crate::ref_layout::RefLayout::empty_chain_anchors) lists that resting commit per such chain, +//! and [`EmptyChainAnchor::joins_owning_chain`](crate::ref_layout::EmptyChainAnchor::joins_owning_chain) distinguishes the two possibilities: +//! the commit is already displayed inside another chain's run (the empties splice into +//! that stack), or no run displays it โ€” then only this chain makes the commit part of the +//! workspace at all, and the frame counts the anchor as a workspace tip of its own. +//! Chains *with* commits are placed as runs and need no anchor. +//! +//! # The lifecycle +//! +//! ```text +//! metadata โ”€โ”€declaresโ”€โ”€โ–ถ RefChain (one stack's ordered branch list) +//! โ–ฒ โ”‚ +//! โ”‚ โ–ผ the build plans from chains + observed refs +//! the walk โ”€โ”€observesโ”€โ”€โ–ถ RefInfo (a ref seen at a commit) +//! โ–ฒ โ”‚ +//! โ”‚ โ–ผ decisions, stored on the CommitGraph +//! โ”‚ RefLayout +//! โ”‚ โ”œโ”€ RefGroup + GroupCarry โ—€โ”€โ”€ THE stored truth +//! โ”‚ โ”œโ”€ RefFacts (per name) +//! โ”‚ โ””โ”€ queried: positioned_on ยท below_of ยท facts_of +//! โ”‚ โ”‚ +//! โ”‚ โ–ผ ingest = id-mapping copy (ObjectId โ†’ PickIndex) +//! โ”‚ RefGroup + GroupCarry โ—€โ”€โ”€ the editor's store +//! โ”‚ โ””โ”€ RefState (per name: mutable, live, ambiguous) +//! โ”‚ โ”‚ +//! โ””โ”€โ”€ materialize โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ refs written to disk; the next walk observes them +//! ``` +//! +//! *Declared* as ref chains, *observed* as `RefInfo`s, *decided* into groups, *edited* as +//! those same groups over pick handles โ€” with positions always asked, never stored. +//! +//! # Why it is the way it is +//! +//! - **Names are the member identity**: a rebase rewrites every commit id under a ref, +//! but the ref is still `feature-a`. Names never churn while commits do. +//! - **Stored fields are decisions, not caches.** Each was checked to be non-derivable: +//! `RefFacts::ambiguous` records a convergence visible only while deriving; +//! `RefFacts::reachable` records which segments the entrypoint's descent actually entered โ€” +//! two layouts can look identical and still differ here; `joins_owning_chain` is the plan's +//! run-ownership call, and re-deriving it would need the workspace bound, which is computed +//! FROM these anchors. +//! - **Remote-tracking links stay out**: they are disk-derived enrichment, not placement +//! decisions. + +/// How much of its commit's incoming edges a reference group carries โ€” which edges ENTER +/// THROUGH the group's references. Generic over the id space: the graph stores commit ids, +/// the rebase editor stores the same shape over its pick handles. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GroupCarry { + /// Nothing descends into this group (a root group: remote above a tip, empty top). + None, + /// Every edge into the commit descends through this group (a plain stack, or the shared + /// group all merge groups converge on). + All, + /// Exactly these stated edges โ€” one group of a merge. Kept sorted and deduplicated, + /// keyed by the full `(child, parent number)` edge: two distinct sources can feed one + /// commit at the same parent number (and one source at two parent numbers), so both coordinates are + /// needed. + Edges(Vec<(Id, usize)>), +} + +/// One group of references standing on a commit: an ordered bottomโ†’top run of member NAMES +/// sharing one [`GroupCarry`] โ€” THE shared shape of the displayโ†”rebase boundary. The builder +/// authors it, the projection derives its row view from it, and the rebase editor ingests +/// it as an id-mapping copy. Order and stacking are LIST STRUCTURE: a member's below is the +/// previous member, its rank its index plus the height of the attach walk underneath. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RefGroup { + /// The reference names, bottomโ†’top: `members[0]` sits on `attach` (or the commit). + pub members: Vec, + /// How much of the commit's edges this group carries. + pub carry: GroupCarry, + /// The reference this group's bottom member sits on โ€” a member of ANOTHER group on the + /// same commit (`None` = directly on the commit). Group boundaries are carry + /// boundaries: a branch point or a carry change starts a new group. + pub attach: Option, +} + +/// The graph-side facts of one reference โ€” everything the projection needs beyond the +/// structural groups. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RefFacts { + /// Whether the entrypoint reaches this ref's segment โ€” mutability before the editor's + /// category gates. + pub reachable: bool, + /// Whether this ref NAMES its segment โ€” the disambiguation winner โ€” rather than riding + /// passively on a commit. + pub names_segment: bool, + /// For naming refs: whether the named segment owns no commits. + pub names_empty_segment: bool, + /// Whether several edges converge right above the ref โ€” a preserved creation-time + /// signal, never re-derived. + pub ambiguous: bool, +} + +/// Where one empty chain rests โ€” a declared chain with no commits of its own only exists +/// as branches sitting on some commit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EmptyChainAnchor { + /// The commit the chain's branches sit on. + pub commit: gix::ObjectId, + /// Whether that commit is already shown inside another chain's run โ€” the empties then splice + /// into that stack. When `false`, only this chain's wiring makes the commit part of + /// the workspace at all, so the frame counts it as a workspace tip of its own. + pub joins_owning_chain: bool, +} + +/// The workspace commit and its materialized parent list โ€” one parent per ref chain, +/// so empty chains over one base yield duplicate entries the real commit does not have. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MaterializedWsParents { + /// The managed workspace commit. + pub commit: gix::ObjectId, + /// Its parents in chain order. + pub parents: Vec, +} + +/// The ref layout: the stored reference groups per commit plus per-reference facts and the +/// workspace anchors. Everything here is something the build DECIDED; nothing is a value +/// you could recompute from the other fields โ€” those are answered by the queries +/// ([`Self::positioned_on`], [`Self::below_of`], [`Self::facts_of`], [`Self::placements`]), +/// so a stored copy can never go stale or disagree. The two fields that look derivable +/// aren't: [`Self::reachable_commits`] follows the plan's segment wiring (empty chains +/// included), not the arena's edges, and [`Self::head_refs`] records the naming decision, +/// not the refs at a commit. +#[derive(Debug, PartialEq, Eq, Clone, Default)] +pub struct RefLayout { + /// THE stored structure: per commit, the ordered reference groups standing on it โ€” + /// the exact shape the rebase editor stores over its own handles. Commits appear in + /// first-namer order (the order their first ref holds in [`Self::facts`]). + pub groups: Vec<(gix::ObjectId, Vec>)>, + /// Per-reference facts, in reference-table order โ€” the order fixes the editor's + /// reference table (and with it render sibling order). Unborn refs (no position) are + /// here too, absent from [`Self::groups`]. + pub facts: Vec<(gix::refs::FullName, RefFacts)>, + /// See [`MaterializedWsParents`]; `None` without a managed entrypoint commit. + pub materialized_ws_parents: Option, + /// The entrypoint's ref names โ€” the editor's HEAD checkouts. + pub head_refs: Vec, + /// Commits reachable from the entrypoint (sorted) โ€” the editor's mutable commits. + pub reachable_commits: Vec, + /// Where the empty chains rest: for each declared chain with no commits of its own, + /// the commit its branches sit on, in metadata order. + pub empty_chain_anchors: Vec, +} + +/// One reference's authoring input to [`RefLayout::from_parts`]: its facts, and โ€” when +/// positioned โ€” its placement. +pub(crate) struct RefPart { + pub name: gix::refs::FullName, + pub facts: RefFacts, + pub placement: Option, +} + +/// A positioned reference's placement: the commit it stands on, the NAME of the reference +/// below it, and its entering edges (sorted). +pub(crate) struct Placement { + pub on: gix::ObjectId, + pub below: Option, + pub entering: Vec<(gix::ObjectId, usize)>, +} + +impl RefLayout { + /// Author a layout from the builder's parts: per reference (table order) its facts and + /// resolved placement (`on`, the NAME below it, its entering edges) โ€” groups authored + /// directly, the position view derived from them. `incoming` answers a commit's full + /// connected incoming edge set, `(child, parent number)` sorted โ€” the [`GroupCarry::All`] + /// classification and expansion oracle. + pub(crate) fn from_parts( + parts: Vec, + incoming: &dyn Fn(gix::ObjectId) -> Vec<(gix::ObjectId, usize)>, + ) -> Self { + // Group placements exactly like the editor's place(): in table order, a ref joins + // the group whose top member is its below and whose carry equals its own โ€” else it + // starts a fresh group attached there. Coalescing keeps groups canonical maximal + // same-carry runs. + // + // One name = one reference is the name-keyed contract; the builder authors each + // name at most once. + let mut groups: Vec<(gix::ObjectId, Vec>)> = Vec::new(); + let mut grouped = std::collections::HashSet::new(); + for part in &parts { + let (name, placement) = (&part.name, &part.placement); + debug_assert!( + grouped.insert(name), + "one name = one reference: '{name}' was authored twice" + ); + let Some(Placement { + on, + below, + entering, + }) = placement + else { + continue; + }; + let carry = if entering.is_empty() { + GroupCarry::None + } else if *entering == incoming(*on) { + GroupCarry::All + } else { + GroupCarry::Edges(entering.clone()) + }; + let attach = below.clone(); + let at = match groups.iter().position(|(id, _)| id == on) { + Some(at) => at, + None => { + groups.push((*on, Vec::new())); + groups.len() - 1 + } + }; + place_in_groups(&mut groups[at].1, name.clone(), carry, attach); + } + RefLayout { + groups, + facts: parts + .into_iter() + .map(|part| (part.name, part.facts)) + .collect(), + ..Default::default() + } + } + + /// The facts of the reference `name`, if the layout knows it. + pub fn facts_of(&self, name: &gix::refs::FullNameRef) -> Option<&RefFacts> { + self.facts + .iter() + .find(|(n, _)| n.as_ref() == name) + .map(|(_, facts)| facts) + } + + /// The reference directly underneath `name` in its group's physical stack โ€” the + /// previous member, or the group's attach for a bottom member; `None` when it sits + /// directly on its commit (or holds no placement at all). + pub fn below_of(&self, name: &gix::refs::FullNameRef) -> Option<&gix::refs::FullName> { + self.groups.iter().find_map(|(_, commit_groups)| { + commit_groups.iter().find_map(|group| { + let i = group.members.iter().position(|m| m.as_ref() == name)?; + match i { + 0 => group.attach.as_ref(), + _ => Some(&group.members[i - 1]), + } + }) + }) + } + + /// The commit the reference `name` stands on โ€” `None` for unborn refs (and unknown + /// names), which keep no stored placement. + pub fn positioned_on(&self, name: &gix::refs::FullNameRef) -> Option { + self.placements() + .find(|(n, _)| n.as_ref() == name) + .map(|(_, on)| on) + } + + /// Every positioned reference with the commit it stands on, in group order (per commit + /// bottomโ†’top). One entry per name โ€” the layout's name-keyed contract. + pub fn placements(&self) -> impl Iterator { + self.groups.iter().flat_map(|(on, commit_groups)| { + commit_groups + .iter() + .flat_map(move |group| group.members.iter().map(move |name| (name, *on))) + }) + } + + /// [`Self::placements`] narrowed to segment-NAMING references (facts say + /// [`RefFacts::names_segment`]) โ€” the placements that are structure, never riders. + pub fn segment_naming_placements( + &self, + ) -> impl Iterator { + let naming: std::collections::HashSet<&gix::refs::FullNameRef> = self + .facts + .iter() + .filter(|(_, facts)| facts.names_segment) + .map(|(name, _)| name.as_ref()) + .collect(); + self.placements() + .filter(move |(name, _)| naming.contains(name.as_ref())) + } +} + +/// Place `name` into `groups`: onto `attach`'s group when it lands on a group top with +/// the same carry, as a fresh group otherwise โ€” then re-coalesce. THE placement +/// algorithm of the name-keyed store, shared by the builder's authoring and the rebase +/// editor's mutations (one implementation on both sides of the boundary). +pub fn place_in_groups( + groups: &mut Vec>, + name: gix::refs::FullName, + carry: GroupCarry, + attach: Option, +) { + let joined = attach.as_ref().and_then(|b| { + groups + .iter() + .position(|group| group.members.last() == Some(b) && group.carry == carry) + }); + match joined { + Some(g) => groups[g].members.push(name), + None => groups.push(RefGroup { + members: vec![name], + carry, + attach, + }), + } + coalesce_groups(groups); +} + +/// Merge groups that stand contiguously with the same carry: a group attached to the TOP +/// member of another group with an equal carry is its continuation โ€” group boundaries stay +/// canonical maximal same-carry runs. +pub fn coalesce_groups(groups: &mut Vec>) { + loop { + let merge = groups.iter().enumerate().find_map(|(upper_idx, upper)| { + let b = upper.attach.as_ref()?; + groups.iter().enumerate().find_map(|(lower_idx, lower)| { + (lower_idx != upper_idx + && lower.members.last() == Some(b) + && lower.carry == upper.carry) + .then_some((lower_idx, upper_idx)) + }) + }); + let Some((lower_idx, upper_idx)) = merge else { + break; + }; + let upper = groups.remove(upper_idx); + let lower_idx = lower_idx - usize::from(upper_idx < lower_idx); + groups[lower_idx].members.extend(upper.members); + } +} From 2c2806f1d35e520edbb8afe377dd450cae9a17f2 Mon Sep 17 00:00:00 2001 From: Mattias Granlund Date: Sun, 12 Jul 2026 11:30:05 +0300 Subject: [PATCH 05/11] Add the workspace projection: stacks straight from the graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The display side: derive the workspace โ€” stacks of first-parent chains, integration status, the frame's bounds โ€” from the CommitGraph, its stored ref layout and the build context. No segment is read anywhere. - the frame: workspace kind, entrypoint, upper/lower bounds, all derived - stack collection: one stack per resolved chain parent in the workspace commit's parent order; positioned naming refs carve the commit runs - post-projection queries answer from the arena: merge-bases, push status, upstream commits Field-for-field parity with the segment-derived projection was proven by exhaustive divergence censuses before the switch (the harnesses retired with the old pipeline); the handful of ruled differences are enumerated in the switch-over PR's description. Unwired until the switch-over. --- .../but-graph/src/projection/api/queries.rs | 480 ++++++ crates/but-graph/src/projection/collect.rs | 1487 +++++++++++++++++ crates/but-graph/src/projection/frame.rs | 618 +++++++ crates/but-graph/src/projection/init.rs | 912 ++++++++++ 4 files changed, 3497 insertions(+) create mode 100644 crates/but-graph/src/projection/api/queries.rs create mode 100644 crates/but-graph/src/projection/collect.rs create mode 100644 crates/but-graph/src/projection/frame.rs create mode 100644 crates/but-graph/src/projection/init.rs diff --git a/crates/but-graph/src/projection/api/queries.rs b/crates/but-graph/src/projection/api/queries.rs new file mode 100644 index 00000000000..785654083ae --- /dev/null +++ b/crates/but-graph/src/projection/api/queries.rs @@ -0,0 +1,480 @@ +//! Discoverable queries over [`Workspace`](crate::Workspace). +//! +//! These functions name the question being asked instead of exposing legacy +//! presentation shapes. + +use anyhow::Context; + +use crate::{Workspace, workspace::StackSegment}; + +/// Legacy query helpers kept for callers that still depend on compatibility +/// semantics. +#[cfg(feature = "legacy")] +#[path = "legacy.rs"] +pub mod legacy; + +/// # Points of Interest +impl Workspace { + /// The name of the segment owning the workspace's lower bound โ€” the ref marking the + /// common base all stacks converge on (e.g. the target's local `main`), if the bound + /// exists and its segment is named. + pub fn lower_bound_ref_name(&self) -> Option<&gix::refs::FullNameRef> { + self.lower_bound_ref_name.as_ref().map(|rn| rn.as_ref()) + } + + /// Return the id of the commit at the tip of the workspace, or that the tip + /// reference was pointing to in Git. + /// + /// Empty virtual workspace tip segments may fan out to multiple stack + /// branches, so the workspace segment has no unique graph path to a commit. + /// This falls back to the peeled commit id stored in the workspace segment's + /// [`crate::RefInfo`]. + /// + /// Note that this commit could also be the base of the workspace, + /// particularly if there are no commits in the workspace. + pub fn tip_commit_id(&self) -> Option { + self.tip_commit_id + } + + /// Where the branch named `name` rests: its own tip commit, or โ€” when it is empty โ€” + /// the first commit of a segment below it in the same stack, or the stack's base. + /// `None` if `name` isn't a workspace stack branch. + pub fn branch_resting_commit_id(&self, name: &gix::refs::FullNameRef) -> Option { + let (stack_idx, seg_idx) = self.find_segment_owner_indexes_by_refname(name)?; + let stack = &self.stacks[stack_idx]; + stack.segments[seg_idx..] + .iter() + .find_map(|segment| segment.commits.first().map(|commit| commit.id)) + .or_else(|| stack.base()) + } + + /// Like [`Self::branch_resting_commit_id()`], but with distinct errors for an unknown + /// branch and for the impossible no-base case. + pub fn try_branch_resting_commit_id( + &self, + name: &gix::refs::FullNameRef, + ) -> anyhow::Result { + let (stack_idx, seg_idx) = self.try_find_segment_owner_indexes_by_refname(name)?; + let stack = &self.stacks[stack_idx]; + stack.segments[seg_idx..] + .iter() + .find_map(|segment| segment.commits.first().map(|commit| commit.id)) + .or_else(|| stack.base()) + .context("BUG: we should always see through to the base or eligible commits") + } + + /// The commit that `name` points to, directly or indirectly: the segment named `name` โ€” + /// or holding a commit that carries `name` in its refs โ€” resolves it, seeing through + /// empty segments. Tags may or may not be in the graph, depending on how it was built. + pub fn commit_id_by_ref_name(&self, name: &gix::refs::FullNameRef) -> Option { + // The workspace ref itself is excluded from the graph's ref mapping; the captured + // tip ref info answers for it. + self.commit_graph().commit_by_ref(name).or_else(|| { + let tip = self.tip_ref_info.as_ref()?; + (tip.ref_name.as_ref() == name).then_some(tip.commit_id)? + }) + } + + /// Return the full [`RefInfo`](crate::RefInfo) of the workspace tip segment, if the tip + /// is named. Unlike [`Self::ref_name()`](Self::ref_name) this includes worktree and + /// pointed-to-commit information. + pub fn tip_ref_info(&self) -> Option<&crate::RefInfo> { + self.tip_ref_info.as_ref() + } + + /// Return the id of the commit the traversal entrypoint sits on โ€” usually where + /// `HEAD` points โ€” or `None` if the entrypoint segment carries no commit. + pub fn entrypoint_commit_id(&self) -> anyhow::Result> { + Ok(self + .entrypoint + .as_ref() + .context("BUG: entrypoint must always be set")? + .commit_id) + } + + /// Return the id of the entry-point commit if it is a + /// managed workspace commit owned by GitButler (per its marker message), + /// looked up and verified via `repo`. + pub fn managed_entrypoint_commit_id( + &self, + repo: &gix::Repository, + ) -> anyhow::Result> { + let Some(entrypoint_commit_id) = self.entrypoint_commit_id()? else { + return Ok(None); + }; + let commit = repo.find_commit(entrypoint_commit_id)?; + let message = commit.message_raw()?; + Ok( + crate::workspace::commit::is_managed_workspace_by_message(message) + .then_some(entrypoint_commit_id), + ) + } + + /// Return `true` if the traversal entrypoint is the workspace tip segment itself. + /// + /// Note the difference to [`Self::is_entrypoint()`](Self::is_entrypoint): that one asks + /// whether no stack segment holds the entrypoint, which is also `true` when the + /// entrypoint sits outside the workspace entirely. + pub fn tip_is_entrypoint(&self) -> anyhow::Result { + Ok(self + .entrypoint + .as_ref() + .context("BUG: entrypoint must always be set")? + .is_ws_tip) + } + + /// Return the stored target commit id. + /// + /// This is the previous target position remembered in workspace metadata. + /// It is normally the base the workspace last integrated with, and + /// intentionally differs from the current tip of the target reference. + pub fn stored_target_commit_id(&self) -> Option { + self.target_commit.as_ref().map(|target| target.commit_id) + } + + /// Return the configured target reference name if the workspace target was + /// resolved to a branch during graph traversal. + /// This is mere convenience and it should only be used for displaying the target ref. + /// For everything else, use [`Self::target_ref`]. + pub fn target_ref_name(&self) -> Option<&gix::refs::FullNameRef> { + self.target_ref + .as_ref() + .map(|target| target.ref_name.as_ref()) + } + + /// Return the commit id that currently acts as the workspace target. + /// + /// This follows the same precedence as operations that need a concrete + /// target side: target ref tip, then stored target commit, then the first + /// integrated traversal tip. + pub fn effective_target_commit_id(&self) -> Option { + self.effective_target_commit_id + } +} + +/// # Carried Data +/// +/// Data assembled during graph construction and carried into the projection. +impl Workspace { + /// The commit graph the segment graph was assembled from โ€” the commit-addressed + /// substrate. Empty only for hand-assembled graphs that never went through the builders. + pub fn commit_graph(&self) -> &crate::CommitGraph { + &self.commit_graph + } + + /// The project metadata as it was resolved when the graph was built. + pub fn project_meta(&self) -> &but_core::ref_metadata::ProjectMeta { + &self.ctx.project_meta + } + + /// Mutable access to the carried project metadata โ€” a simulation knob: change it, then + /// [`redo`](Self::redo) to see the workspace under the altered resolution. + pub fn project_meta_mut(&mut self) -> &mut but_core::ref_metadata::ProjectMeta { + &mut self.ctx.project_meta + } + + /// Mutable access to the carried traversal options โ€” a simulation knob: change them, then + /// [`redo`](Self::redo) to see the workspace under the altered traversal. + pub fn options_mut(&mut self) -> &mut crate::walk::Options { + &mut self.ctx.options + } + + /// All remote names that aren't URLs, as retrieved during the traversal. Useful to + /// extract remote names from remote-tracking refs like `refs/remotes/origin/master`, + /// which may have slashes in them. + pub fn symbolic_remote_names(&self) -> &[String] { + &self.ctx.symbolic_remote_names + } + + /// Return `true` if the graph traversal was stopped early due to a hard limit, + /// meaning the graph โ€” and thus this projection โ€” may be incomplete. + pub fn hard_limit_hit(&self) -> bool { + self.commit_graph().hard_limit_hit + } + + /// The LOCAL branch tracking `remote`, as the builder derived it from the repository + /// (a git-configured binding, or name-deduction against the workspace's symbolic remotes). + /// `None` when no local tracks `remote`, or for graphs not born from the builders. + pub fn local_tracking_branch( + &self, + remote: &gix::refs::FullNameRef, + ) -> Option<&gix::refs::FullName> { + self.ctx + .remote_tracking + .iter() + .find_map(|(local, r)| (r.as_ref() == remote).then_some(local)) + } + + /// The remote-tracking branch of `local`, the forward direction of + /// [`Self::local_tracking_branch()`]. + pub fn remote_tracking_branch( + &self, + local: &gix::refs::FullNameRef, + ) -> Option<&gix::refs::FullName> { + self.ctx.remote_tracking.get(local) + } +} + +/// # Diagnostics +/// +/// Debug-tooling views of the carried commit graph. +impl Workspace { + /// Validate the carried commit graph's index and edge coherence, returning every + /// violation. + pub fn validation_errors(&self) -> Vec { + self.commit_graph().validation_errors() + } + + /// Aggregate counts over the carried commit graph. + pub fn statistics(&self) -> crate::CommitGraphStatistics { + self.commit_graph().statistics() + } + + /// The carried commit graph as a Graphviz `dot` document, very large graphs pruned + /// from the bottom. + pub fn dot_graph_pruned(&self) -> String { + self.commit_graph().dot_graph() + } + + /// Render the carried commit graph as an SVG and open it in the default viewer. + #[cfg(unix)] + pub fn open_graph_as_svg(&self) { + crate::debug::open_dot_as_svg(&self.commit_graph().dot_graph()); + } + + /// The carried graph's alternate Debug output. + pub fn graph_debug_string(&self) -> String { + self.commit_graph().debug_string() + } + + /// Render `commit` exactly like the graph debug output does โ€” entrypoint marker, stop + /// condition, remote/local kind, flags, and refs โ€” with worktree ownership context and + /// traversal state (hard limit) read from this workspace. + pub fn commit_debug_string( + &self, + commit: &crate::Commit, + is_entrypoint: bool, + stop_condition: Option, + ) -> String { + crate::debug::commit_debug_string_inner( + commit, + is_entrypoint, + stop_condition, + self.commit_graph().hard_limit_hit, + self.has_multiple_worktrees, + ) + } +} + +/// # Sets of Interest +impl Workspace { + /// All commit ids reachable from `included` but not from `excluded`, newest first + /// (optionally first-parent only). Both ids must be in the graph. + pub fn commit_ids_in_a_not_b( + &self, + included: gix::ObjectId, + excluded: gix::ObjectId, + first_parent: crate::FirstParent, + ) -> anyhow::Result> { + let cg = self.commit_graph(); + for id in [included, excluded] { + anyhow::ensure!(cg.node(id).is_some(), "commit {id} is not in the graph"); + } + let first_parent: bool = first_parent.into(); + let parents = |id: gix::ObjectId| -> Vec { + if first_parent { + cg.first_parent(id).into_iter().collect() + } else { + cg.all_parent_ids(id) + } + }; + let mut hidden = std::collections::HashSet::new(); + let mut stack = vec![excluded]; + while let Some(id) = stack.pop() { + if hidden.insert(id) { + stack.extend(parents(id)); + } + } + // Newest-first by generation, insertion order breaking ties โ€” the commit-level + // equivalent of the segment walk's generation order. + let mut out = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut sequence = 0usize; + let mut queue = std::collections::BinaryHeap::new(); + let push = |queue: &mut std::collections::BinaryHeap<_>, id, sequence: &mut usize| { + let generation = cg.generation_of(id).unwrap_or_default(); + queue.push((generation, std::cmp::Reverse(*sequence), id)); + *sequence += 1; + }; + push(&mut queue, included, &mut sequence); + while let Some((_, _, id)) = queue.pop() { + if !seen.insert(id) || hidden.contains(&id) || cg.node(id).is_none() { + continue; + } + out.push(id); + for parent in parents(id) { + push(&mut queue, parent, &mut sequence); + } + } + Ok(out) + } + + /// Visit the commits in the ancestry of the workspace tip segment โ€” excluding that + /// segment's own commits โ€” in traversal order, stopping segment-wise at the workspace + /// [lower bound](Self::lower_bound). Return `true` from `visit` to stop the traversal. + /// + /// This is useful to search the workspace history, e.g. for a workspace commit buried + /// beneath commits made by hand. + pub fn visit_commits_below_tip(&self, mut visit: impl FnMut(&crate::Commit) -> bool) { + let cg = self.commit_graph(); + for id in &self.commits_below_tip { + let Some(node) = cg.node(*id) else { continue }; + if visit(node) { + return; + } + } + } + + /// Return all target-reference commits that are ahead of the workspace base, + /// which is the commits counted with + /// [workspace::TargetRef::commits_ahead](crate::workspace::TargetRef::commits_ahead) + /// + /// The traversal starts at the resolved target reference and stops at the + /// workspace lower bound or at commits already marked as belonging to the + /// workspace. The result is ordered in graph traversal order from newer + /// commits toward older commits. + pub fn incoming_target_commit_ids(&self) -> anyhow::Result> { + self.incoming_target_commit_ids + .clone() + .context("incoming target commits require a workspace with a target ref") + } + + /// Return the ids of all target-side commits that are not part of the workspace's + /// shared history, in traversal order from the target tip downward. + /// Return `None` if the workspace has no notion of a target at all. + /// + /// The target side is the target ref if resolved, otherwise the stored target commit. + /// + /// RULING (2026-07-04): a DISJOINT target contributes no upstream commits. One pass + /// decides everything on the carried commit graph: paint the lower bound's ancestor set + /// (= shared history, following connected edges only), then walk down from the target + /// tip collecting commits until the walk TOUCHES shared history. Diverged commits are + /// never in that set, so a rewritten remote is collected at any depth; if the walk never + /// touches it, target and workspace share no history โ€” as far as the graph can see โ€” + /// and nothing is upstream. + pub fn upstream_commit_ids_outside_shared_history(&self) -> Option> { + let cg = self.commit_graph(); + let target_tip = self + .target_ref + .as_ref() + .and_then(|t| cg.commit_by_ref(t.ref_name.as_ref())) + .or_else(|| self.target_commit.as_ref().map(|t| t.commit_id))?; + let shared_history = self.lower_bound.and_then(|lb| { + // An empty (default) commit graph โ€” hand-assembled graphs โ€” knows no ancestry. + cg.node(lb).is_some().then(|| cg.ancestor_set(lb)) + }); + let mut upstream_commits = Vec::new(); + let mut touched_shared_history = false; + let mut seen = std::collections::HashSet::new(); + let mut queue = std::collections::VecDeque::from([target_tip]); + while let Some(id) = queue.pop_front() { + if !seen.insert(id) { + continue; + } + let in_shared_history = shared_history.as_ref().is_some_and(|s| s.contains(&id)); + touched_shared_history |= in_shared_history; + if in_shared_history || Some(id) == self.lower_bound || cg.node(id).is_none() { + continue; + } + upstream_commits.push(id); + queue.extend(cg.all_parent_ids(id)); + } + if shared_history.is_some() && !touched_shared_history { + // Never touching shared history means either a truly DISJOINT target (nothing is + // upstream) or a target whose connection point lies beyond the traversal window + // (a diverged remote cut short by limits โ€” its commits ARE upstream). The graph + // records where the traversal cut: if every collected commit's ancestry is fully + // present and roots out without meeting shared history, it is genuinely disjoint. + let genuinely_disjoint = !upstream_commits.iter().any(|id| cg.has_cut_parents(*id)) + && !shared_history + .iter() + .flatten() + .any(|id| cg.has_cut_parents(*id)); + if genuinely_disjoint { + upstream_commits.clear(); + } + } + Some(upstream_commits) + } + + /// Return a [`StackTip`] for each segment strictly below the + /// reference `name` that coincides with a workspace stack tip โ€” matched by segment id, + /// or by first commit id to also catch the same commit in a differently segmented spot. + /// The traversal prunes below each match. + /// + /// Return `None` if `name` isn't in the graph at all, which callers may not consider fatal. + /// + /// This tells which stacks a to-be-applied branch already flows into, i.e. which stacks + /// it would supersede. + pub fn stack_tip_segments_below_ref( + &self, + name: &gix::refs::FullNameRef, + ) -> Option> { + let cg = self.commit_graph(); + // Only refs that NAME a segment participate โ€” a passive (e.g. ambiguous) ref does + // not reach into the workspace on its own. Empty stacks have no tip commit and are + // never superseded. + let layout = cg.layout.as_ref()?; + if !layout.facts_of(name)?.names_segment { + return None; + } + let on = layout.positioned_on(name)?; + let stack_tips: Vec<(gix::ObjectId, &StackSegment)> = self + .stacks + .iter() + .filter_map(|stack| { + let tip = stack.segments.first()?; + tip.commits.first().map(|c| (c.id, tip)) + }) + .collect(); + + let mut matches = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let names_empty = layout + .facts_of(name) + .is_some_and(|facts| facts.names_empty_segment); + let mut queue: std::collections::VecDeque = if names_empty { + // An empty named segment rests ON its anchor: the anchor's owner is the + // first thing below it. + std::collections::VecDeque::from([on]) + } else { + // An owning segment: everything below starts at its tip's parent edges. + cg.all_parent_ids(on).into() + }; + while let Some(id) = queue.pop_front() { + if !seen.insert(id) { + continue; + } + if let Some((_, segment)) = stack_tips.iter().find(|(cid, _)| *cid == id) { + // Prune below a match, like the segment traversal did. + matches.push(StackTip { + ref_name: segment.ref_name().map(|rn| rn.to_owned()), + commit_id: segment.commits.first().map(|c| c.id), + }); + continue; + } + queue.extend(cg.all_parent_ids(id)); + } + Some(matches) + } +} + +/// A workspace stack tip in plain data, as returned by +/// [`Workspace::stack_tip_segments_below_ref()`]. +#[derive(Debug, Clone)] +pub struct StackTip { + /// The name of the tip segment, if it is named. + pub ref_name: Option, + /// The tip segment's first commit, absent for empty segments. + pub commit_id: Option, +} diff --git a/crates/but-graph/src/projection/collect.rs b/crates/but-graph/src/projection/collect.rs new file mode 100644 index 00000000000..93ef8659c66 --- /dev/null +++ b/crates/but-graph/src/projection/collect.rs @@ -0,0 +1,1487 @@ +//! Derive workspace stacks straight from the arena and its stored ref layout โ€” +//! no segment is read. + +use std::collections::{HashMap, HashSet}; + +use crate::workspace::{GraphContext, Stack, StackCommit, StackSegment}; +use crate::{CommitGraph, RefInfo}; +use but_core::ref_metadata; + +/// How the view anchors; only these three states are legal (a managed COMMIT implies a +/// managed ref). +#[derive(Clone, Copy, PartialEq, Eq)] +enum ViewAnchor { + /// The anchor is a managed workspace merge commit (with metadata). + ManagedCommit, + /// A managed workspace REF exists, but its commit is missing or unmanaged. + ManagedRefOnly, + /// A plain checkout: no managed ref at all. + AdHoc, +} + +impl ViewAnchor { + fn new(managed_commit: bool, has_managed_ref: bool) -> Self { + if managed_commit { + debug_assert!(has_managed_ref, "a managed commit implies a managed ref"); + Self::ManagedCommit + } else if has_managed_ref { + Self::ManagedRefOnly + } else { + Self::AdHoc + } + } + /// The view is NOT anchored on a managed merge commit. + fn ad_hoc(self) -> bool { + !matches!(self, Self::ManagedCommit) + } + /// The anchor IS a managed workspace merge commit. + fn managed_commit(self) -> bool { + matches!(self, Self::ManagedCommit) + } + /// A managed workspace ref exists. + fn has_managed_ref(self) -> bool { + !matches!(self, Self::AdHoc) + } +} + +/// Derive the workspace's stacks from the layout the builder stored on `cg` and the +/// workspace metadata's stack lists. +/// +/// The parent array drives: one stack per materialized parent, in parent order โ€” +/// metadata-less parents included. A parent claiming a ref chain (matched by the +/// chain's top position) gets its seed-born empty branches from the metadata list, +/// which is what the stored layout cannot carry. Positioned naming refs carve the +/// commit runs; another chain's naming ref ends a stack's territory, while chainless +/// naming refs (a reachable target local, say) become segments within the reaching +/// stack. `lower_bound` is the workspace frame's bound. +/// +/// Returns `None` when the graph has no layout to read (unborn refs) โ€” the caller +/// falls back to a single named empty stack. +pub(crate) fn stacks_from_arena( + cg: &CommitGraph, + ctx: &GraphContext, + lower_bound: Option, + ws_meta: Option<&ref_metadata::Workspace>, + has_managed_ref: bool, + entry_ref: Option<&gix::refs::FullName>, + entry_commit: Option, +) -> Option> { + let layout = cg.layout()?; + let (ws_commit, materialized_ws_parents) = resolve_view_anchor(cg, layout, ws_meta, entry_ref)?; + let in_ws_stacks: Vec<_> = ws_meta + .map(|meta| { + meta.stacks + .iter() + .filter(|stack| stack.is_in_workspace()) + .collect() + }) + .unwrap_or_default(); + + if entry_is_unapplied_branch( + cg, + ws_commit, + ws_meta, + &in_ws_stacks, + has_managed_ref, + entry_ref, + ) { + return Some(Vec::new()); + } + let anchor = ViewAnchor::new( + cg.is_managed_ws_commit(ws_commit) && ws_meta.is_some(), + has_managed_ref, + ); + // A detached checkout doesn't speak for the branches at its commit. + let detached = cg.seeds.iter().any(|t| t.is_entrypoint && t.is_detached); + // The entrypoint commit anchors cutting and steering while it LIVES in the + // arena; a redone traversal's remembered commit can be stale, and then the + // entry ref's position speaks instead. + let effective_entrypoint = entry_commit + .or_else(|| entry_ref.and_then(|e| layout.positioned_on(e.as_ref()))) + .or_else(|| cg.entrypoint()); + let idx = index_layout(cg, layout, anchor); + let mut chain_of_name = HashMap::<&gix::refs::FullNameRef, usize>::new(); + for (li, meta_stack) in in_ws_stacks.iter().enumerate() { + for branch in &meta_stack.branches { + chain_of_name.insert(branch.ref_name.as_ref(), li); + } + } + let entry_ref = entry_ref.or_else(|| cg.entrypoint_ref()); + let real_ws_parents = if anchor.ad_hoc() { + vec![ws_commit] + } else { + cg.all_parent_ids(ws_commit) + }; + let (order, mut claimed) = build_run_order( + cg, + &in_ws_stacks, + &idx.pos_by_name, + ws_commit, + &real_ws_parents, + &materialized_ws_parents, + anchor, + lower_bound, + ); + + let mut unclaimed_empties_at = + unclaimed_empties_in_table_order(cg, layout, &idx, &in_ws_stacks, &claimed); + + // A run-owning parent resting ON the bound has first claim to the bound's empties. + let bound_run_exists = order + .iter() + .any(|r| r.owns_run && Some(r.start) == lower_bound); + // Empties anchored ON a run's start belong to that run's own stack โ€” a sibling + // stack passing through the commit leaves them alone. + let owned_run_starts: HashSet = order + .iter() + .filter(|r| r.owns_run) + .map(|r| r.start) + .collect(); + let walk = RunWalk { + cg, + ctx, + layout, + idx, + chain_of_name, + in_ws_stacks, + owned_run_starts, + bound_run_exists, + anchor, + detached, + entry_ref, + effective_entrypoint, + reaches_entrypoint: effective_entrypoint + .map(|ep| cg.reaches_marks(ep)) + .unwrap_or_default(), + lower_bound, + ws_meta, + }; + let mut stacks = Vec::new(); + // Stack ids already handed out, chain-claimed ones included. + let mut bound_ids = HashSet::::new(); + for run in &order { + stacks.extend(walk.collect_stack( + run, + &mut claimed, + &mut bound_ids, + &mut unclaimed_empties_at, + )); + } + split_at_bound_empties_from_content_stacks(&mut stacks, &walk, lower_bound, anchor); + prune_all_integrated_anonymous(&mut stacks); + // A TRUE ad-hoc view always has its one stack, if only an anonymous shell โ€” + // a detached checkout of fully integrated territory still IS a checkout. + // (A managed-ref workspace can legitimately be empty.) + if anchor == ViewAnchor::AdHoc && stacks.is_empty() { + stacks.push(Stack::from_base_and_segments_raw( + vec![StackSegment::default()], + (!anchor.has_managed_ref()).then(but_core::ref_metadata::StackId::single_branch_id), + )); + } + Some(stacks) +} + +/// One run of the workspace: the metadata chain claiming it (if any), the commit +/// the run starts on, and whether this stack owns and walks the run โ€” a chain +/// claiming a REAL parent edge (or the entry commit) owns its run, even a chain of +/// only empty branches, since the builder dedicated that materialized parent to it. +/// Fresh-inserted chains rest on commits owned elsewhere and never walk. +struct Run { + chain: Option, + start: gix::ObjectId, + owns_run: bool, +} + +/// The projection's per-name layout index: each known ref's facts and, when positioned, +/// the commit it stands on. +type PosByName<'a> = + HashMap<&'a gix::refs::FullNameRef, (&'a crate::ref_layout::RefFacts, Option)>; + +/// A chain claims a run when one of its positioned branches sits on the run's +/// start โ€” metadata branch lists don't reliably order top-to-bottom, so this +/// checks them all. Real branches speak for the chain when it has any; the +/// anchors of its empties count only for a chain of nothing but empties, lest a +/// dependent empty resting on another stack's commit steal that stack's run. +fn chain_claims( + pos_by_name: &PosByName<'_>, + meta_stack: &but_core::ref_metadata::WorkspaceStack, + key: gix::ObjectId, +) -> bool { + let mut positions = meta_stack.branches.iter().filter_map(|b| { + pos_by_name + .get(b.ref_name.as_ref()) + .filter(|(facts, _)| facts.names_segment) + .map(|&(facts, on)| (facts.names_empty_segment, on)) + }); + let has_real = positions.clone().any(|(empty, _)| !empty); + positions.any(|(empty, on)| empty != has_real && on == Some(key)) +} + +/// The stack sequence mirrors how the builder wires the workspace's edges: the +/// commit's real parents in parent order (a chain topping out on a parent replaces +/// that edge in place), then chains without a parent edge INSERT at their metadata +/// index โ€” the builder's fresh-connection rule. +/// +/// A managed workspace commit is a pure merge whose parents are the stacks; an +/// UNMANAGED entrypoint commit (a bare `gitbutler/workspace` ref, a plain branch +/// shape, or a managed commit with NO workspace metadata โ€” legacy drift) is +/// itself stack content โ€” the single walk starts on it. +/// +/// `real_ws_parents` is the merge commit's actual parent array; `materialized_ws_parents` +/// (the layout's stored `materialized_ws_parents`) is the parent list the metadata +/// implies โ€” one entry per chain, topโ†’bottom, duplicates where empty chains share a +/// base and fresh entries where the builder wired a chain without a real edge. The +/// two differ exactly on the stacks a git parent array cannot express. +/// +/// Returns the runs plus which metadata chains were claimed. +/// +/// This matching CANNOT move to the builder: a stored parent order can disagree with metadata order +/// (the merge on disk predates a metadata reorder), and in ws-commit-less configs +/// the run candidates come from the frame's view anchor โ€” entry-inside, seed and +/// bound knowledge that only exists at projection time. (An attempt to compute +/// this at build time ended up re-implementing the frame's anchor choice case by case, +/// so the matching stays here.) +#[expect(clippy::too_many_arguments)] +fn build_run_order( + cg: &CommitGraph, + in_ws_stacks: &[&ref_metadata::WorkspaceStack], + pos_by_name: &PosByName<'_>, + ws_commit: gix::ObjectId, + real_ws_parents: &[gix::ObjectId], + materialized_ws_parents: &[gix::ObjectId], + anchor: ViewAnchor, + lower_bound: Option, +) -> (Vec, Vec) { + let mut claims = RunClaims { + cg, + in_ws_stacks, + pos_by_name, + lower_bound, + order: Vec::new(), + claimed: vec![false; in_ws_stacks.len()], + claimed_names: HashSet::new(), + }; + if anchor.managed_commit() { + claims.claim_chains_topping_the_entry(ws_commit); + } + claims.claim_real_parents(real_ws_parents); + claims.claim_surplus_materialized_parents(materialized_ws_parents, real_ws_parents); + // Unclaimed chains resting on the bound still surface as stacks at their + // metadata index, with or without stored chain parents. A workspace REF makes this + // a multi-stack workspace even without a managed commit or metadata reconciliation. + if anchor.has_managed_ref() { + claims.note_names_of_claimed_chains(); + if materialized_ws_parents.is_empty() { + claims.claim_duplicate_runs(); + } + claims.claim_at_bound_empties(); + } + (claims.order, claims.claimed) +} + +/// Local empties whose chain never claimed a run (or that no chain lists) still splice +/// in wherever their anchor is walked โ€” the first stack through the commit takes them, +/// in positioned order. +/// +/// Table order, not group order: the first stack through a commit takes its empties in +/// the order the layout registered them. Group order LOOKS equivalent but is not โ€” +/// same-commit empties from different chains flip their vertical order under it. +fn unclaimed_empties_in_table_order<'a>( + cg: &CommitGraph, + layout: &'a crate::ref_layout::RefLayout, + idx: &LayoutIndexes<'_>, + in_ws_stacks: &[&ref_metadata::WorkspaceStack], + claimed: &[bool], +) -> HashMap> { + let mut unclaimed_empties_at = HashMap::>::new(); + for (name, _) in layout.facts.iter().filter(|(name, facts)| { + facts.names_segment + && facts.names_empty_segment + && name.category() == Some(gix::reference::Category::LocalBranch) + && !is_implementation_ref(cg, name.as_ref()) + }) { + if in_ws_stacks.iter().enumerate().any(|(li, m)| { + claimed[li] + && m.branches + .iter() + .any(|b| b.ref_name.as_ref() == name.as_ref()) + }) { + continue; + } + let Some(&(_, Some(on))) = idx.pos_by_name.get(name.as_ref()) else { + continue; + }; + unclaimed_empties_at.entry(on).or_default().push(name); + } + unclaimed_empties_at +} + +/// The claim passes of [`build_run_order`], in call order, over the shared claim state: +/// each pass matches unclaimed metadata chains to run-start commits its own way. +struct RunClaims<'a> { + cg: &'a CommitGraph, + in_ws_stacks: &'a [&'a ref_metadata::WorkspaceStack], + pos_by_name: &'a PosByName<'a>, + lower_bound: Option, + // (chain, run start, owns_run): a chain claiming a REAL parent edge (or the entry + // commit) owns its run and walks it โ€” even a chain of only empty branches, since + // the builder dedicated that materialized parent to it. Fresh-inserted chains rest on + // commits owned elsewhere and never walk. + order: Vec, + claimed: Vec, + claimed_names: HashSet<&'a gix::refs::FullNameRef>, +} + +impl<'a> RunClaims<'a> { + /// A metadata chain topping out ON the entry commit itself (a workspace ref listed + /// as its own stack branch โ€” unreconciled legacy shapes) claims a run that starts + /// there; the parent walks below then find everything already collected. + fn claim_chains_topping_the_entry(&mut self, ws_commit: gix::ObjectId) { + for (li, meta) in self.in_ws_stacks.iter().enumerate() { + if !self.claimed[li] && chain_claims(self.pos_by_name, meta, ws_commit) { + self.claimed[li] = true; + self.order.push(Run { + chain: Some(li), + start: ws_commit, + owns_run: true, + }); + } + } + } + + /// Every real parent edge is a run in parent order, claimed by the chain whose + /// positioned branch sits on it (anonymous when none does). + fn claim_real_parents(&mut self, real_ws_parents: &[gix::ObjectId]) { + for parent in real_ws_parents { + let chain = self.in_ws_stacks.iter().enumerate().find_map(|(li, meta)| { + (!self.claimed[li] && chain_claims(self.pos_by_name, meta, *parent)).then_some(li) + }); + if let Some(li) = chain { + self.claimed[li] = true; + } + self.order.push(Run { + chain, + start: *parent, + owns_run: true, + }); + } + } + + /// The chain parents ARE the resolved set of runs: every real parent plus a fresh + /// connection per chain the builder wired in besides them (duplicates meaning two + /// chains share one run). Each surplus entry claims its chain and inserts at the + /// chain's metadata index โ€” the builder's fresh-connection order. + fn claim_surplus_materialized_parents( + &mut self, + materialized_ws_parents: &[gix::ObjectId], + real_ws_parents: &[gix::ObjectId], + ) { + let mut surplus = materialized_ws_parents.to_vec(); + for parent in real_ws_parents { + if let Some(i) = surplus.iter().position(|s| s == parent) { + surplus.remove(i); + } + } + for parent in surplus { + let Some(li) = self.in_ws_stacks.iter().enumerate().find_map(|(li, meta)| { + (!self.claimed[li] && chain_claims(self.pos_by_name, meta, parent)).then_some(li) + }) else { + continue; + }; + self.insert_claim( + li, + Run { + chain: Some(li), + start: parent, + owns_run: true, + }, + ); + } + } + + /// Seed `claimed_names` from the chains claimed so far โ€” the later passes skip + /// branches another chain already materialized. + fn note_names_of_claimed_chains(&mut self) { + for (li, meta) in self.in_ws_stacks.iter().enumerate() { + if self.claimed[li] { + self.claimed_names + .extend(meta.branches.iter().map(|b| b.ref_name.as_ref())); + } + } + } + + /// A second chain keyed on an already-claimed run start duplicates that + /// run โ€” the analog of a duplicated chain parent when none are stored. A chain + /// whose real branch rests elsewhere gets a fresh run from that position. Only + /// without stored chain parents: with them, real branches already claimed their + /// runs. + fn claim_duplicate_runs(&mut self) { + for (li, meta) in self.in_ws_stacks.iter().enumerate() { + if self.claimed[li] + || meta + .branches + .iter() + .all(|b| self.claimed_names.contains(b.ref_name.as_ref())) + { + continue; + } + let key = self + .order + .iter() + .map(|r| r.start) + .find(|k| chain_claims(self.pos_by_name, meta, *k)) + .or_else(|| { + meta.branches + .iter() + .find_map(|b| { + self.pos_by_name + .get(b.ref_name.as_ref()) + .filter(|(facts, _)| { + facts.names_segment + && !facts.names_empty_segment + && facts.reachable + }) + .and_then(|&(_, on)| on) + }) + // ... but only OUTSIDE every other stack's reach; a position + // some stack walks over is that stack's segment, not its own stack. + .filter(|on| !self.order.iter().any(|r| reaches(self.cg, r.start, *on))) + }); + let Some(key) = key else { continue }; + self.claimed_names + .extend(meta.branches.iter().map(|b| b.ref_name.as_ref())); + self.insert_claim( + li, + Run { + chain: Some(li), + start: key, + owns_run: true, + }, + ); + } + } + + /// Last resort: an unclaimed chain of nothing but empties resting ON the + /// exclusive bound surfaces as its own (non-walking) stack. Mid-run anchors + /// splice into the stack that walks them instead. + fn claim_at_bound_empties(&mut self) { + for (li, meta) in self.in_ws_stacks.iter().enumerate() { + if self.claimed[li] { + continue; + } + // Metadata can list one branch in several stacks mid-operation; the + // first stack that materialized it keeps it. + if meta + .branches + .iter() + .all(|b| self.claimed_names.contains(b.ref_name.as_ref())) + { + continue; + } + // A branch whose commit IS the exclusive bound has an empty segment too + // (nothing sits strictly above the bound), even where the builder marked + // it non-empty for naming a real commit โ€” or gave naming rights to a + // sibling ref on the same commit. + let is_empty_pos = |facts: &crate::ref_layout::RefFacts, on: Option| { + (facts.names_segment && facts.names_empty_segment) + || (on.is_some() && on == self.lower_bound) + }; + let all_empty_anchor = meta.branches.iter().find_map(|b| { + let &(facts, on) = self.pos_by_name.get(b.ref_name.as_ref())?; + is_empty_pos(facts, on).then_some(on).flatten() + }); + let any_real = meta.branches.iter().any(|b| { + self.pos_by_name + .get(b.ref_name.as_ref()) + .is_some_and(|&(facts, on)| facts.names_segment && !is_empty_pos(facts, on)) + }); + let Some(anchor) = all_empty_anchor + .filter(|_| !any_real) + .filter(|a| Some(*a) == self.lower_bound) + else { + continue; + }; + self.claimed_names + .extend(meta.branches.iter().map(|b| b.ref_name.as_ref())); + self.insert_claim( + li, + Run { + chain: Some(li), + start: anchor, + owns_run: false, + }, + ); + } + } + + /// Claim chain `li` and insert its run at the chain's metadata index. + fn insert_claim(&mut self, li: usize, run: Run) { + self.claimed[li] = true; + let at = li.min(self.order.len()); + self.order.insert(at, run); + } +} + +/// A chain member that is EMPTY and anchored on the exclusive bound defects from +/// its chain: it rests on base territory below the chain's real content, so it +/// stands as its own stack (keeping the chain's identity and position), while the +/// content remainder becomes a new stack appended at the bottom. All-empty chains +/// stay whole โ€” there is no content to separate from. Operations then persist the +/// split back to metadata, converging on two chains. +fn split_at_bound_empties_from_content_stacks( + stacks: &mut Vec, + walk: &RunWalk<'_>, + lower_bound: Option, + anchor: ViewAnchor, +) { + if anchor.ad_hoc() || lower_bound.is_none() { + return; + } + let mut appended = Vec::new(); + for stack in stacks.iter_mut() { + if stack.segments.len() < 2 { + continue; + } + // The stack's metadata chain gives each branch its declared rank (0 = top). + let Some(chain) = stack + .id + .and_then(|id| walk.in_ws_stacks.iter().find(|m| m.id == id)) + else { + continue; + }; + let rank_of = |name: &gix::refs::FullNameRef| { + chain + .branches + .iter() + .position(|b| b.ref_name.as_ref() == name) + }; + // The highest-ranked (closest to top) content-bearing member. + let Some(top_content_rank) = stack + .segments + .iter() + .filter(|seg| !seg.commits.is_empty()) + .filter_map(|seg| seg.ref_name().and_then(rank_of)) + .min() + else { + continue; + }; + // A member defects when it is empty, rests ON the bound, and its metadata + // rank puts it ABOVE the content โ€” an inversion the chain layout cannot + // express (physically it sits on base territory BELOW that content). A + // dependent empty declared below its content is consistent and stays. + let defects = |seg: &StackSegment| { + seg.commits.is_empty() + && seg.ref_name().is_some_and(|name| { + walk.idx + .pos_by_name + .get(name) + .is_some_and(|&(_, on)| on.is_some() && on == lower_bound) + && rank_of(name).is_some_and(|rank| rank < top_content_rank) + }) + }; + if !stack.segments.iter().any(&defects) { + continue; + } + let (defectors, remainder): (Vec<_>, Vec<_>) = + stack.segments.drain(..).partition(|seg| defects(seg)); + // The defector keeps the chain's stack identity and position; the content + // remainder is the newly-arranged entity and settles at the bottom. + let remainder_stack = Stack::from_base_and_segments_raw(remainder, None); + stack.segments = defectors; + appended.push(remainder_stack); + } + stacks.extend(appended); +} + +/// Everything the per-run walk reads. The mutable claim state (claimed chains, +/// bound stack ids, unclaimed empties) is passed into [`Self::collect_stack`] +/// separately so the borrows stay obvious. +struct RunWalk<'a> { + cg: &'a CommitGraph, + ctx: &'a GraphContext, + layout: &'a crate::ref_layout::RefLayout, + idx: LayoutIndexes<'a>, + chain_of_name: HashMap<&'a gix::refs::FullNameRef, usize>, + in_ws_stacks: Vec<&'a ref_metadata::WorkspaceStack>, + owned_run_starts: HashSet, + bound_run_exists: bool, + anchor: ViewAnchor, + detached: bool, + entry_ref: Option<&'a gix::refs::FullName>, + effective_entrypoint: Option, + /// Per node: whether the entrypoint is among its ancestors โ€” the merge-steering + /// question, answered once for the whole arena instead of per merge. + reaches_entrypoint: Vec, + lower_bound: Option, + ws_meta: Option<&'a ref_metadata::Workspace>, +} + +/// The named-segment assembly a run walk accumulates: finished segments, the +/// names already emitted (a name never names two segments in one stack), and +/// the segment currently being filled. +struct SegmentsInProgress<'a> { + segments: Vec, + emitted: HashSet<&'a gix::refs::FullNameRef>, + current: Option, +} + +impl SegmentsInProgress<'_> { + fn new() -> Self { + SegmentsInProgress { + segments: Vec::new(), + emitted: HashSet::new(), + current: None, + } + } + + /// Close `current`, keeping it unless it is an anonymous shell that only + /// existed to carry outside commits and never received any. + fn flush_current(&mut self) { + if let Some(seg) = self.current.take() + && (!seg.commits.is_empty() || seg.commits_outside.is_none()) + { + self.segments.push(seg); + } + } +} + +/// What the naming pass decided for one walked commit. +enum SegmentCut<'a, 's> { + /// The commit is named by a branch whose chain claimed another stack โ€” + /// this run's territory ends here. + ForeignTerritory, + /// The commit starts a new named segment; `projected_outside` carries the + /// outside commits when the name came from an outside projection. + Named { + name: &'a gix::refs::FullName, + projected_outside: Option<&'s Vec>, + }, + /// No name speaks for this commit โ€” it rides in the current (or a fresh + /// anonymous) segment. + Anonymous, +} + +impl<'a> RunWalk<'a> { + /// Walk one run into a stack: this chain's (or chainless) naming refs cut + /// segments, another stack's naming ref or the workspace bound ends the + /// territory, and leftover empties settle at the stack's bottom in metadata + /// order. Returns `None` when the run contributed nothing. + fn collect_stack( + &self, + run: &Run, + claimed: &mut [bool], + bound_ids: &mut HashSet, + unclaimed_empties_at: &mut HashMap>, + ) -> Option { + let (li, chain_id, branch_names) = self.claim_chain(run, claimed, bound_ids); + + // The chain's branch list in metadata order (top โ†’ bottom): empties splice in + // wherever their anchor lies โ€” above the run, between runs, or below the + // bound. The walk emits each named branch's run when it reaches it; whatever + // never got reached (anchors at or below the bound) settles as empty + // segments afterwards, in metadata order. + let mut pending = self.pending_empties(&branch_names); + let mut bound_rest: Vec<&gix::refs::FullName> = Vec::new(); + let mut sip = SegmentsInProgress::new(); + + let last_base = self.walk_run( + run, + li, + &branch_names, + claimed, + unclaimed_empties_at, + &mut pending, + &mut bound_rest, + &mut sip, + ); + sip.flush_current(); + + self.settle_leftover_empties(pending, bound_rest, &mut sip); + + let last_base = if run.owns_run { + last_base + } else { + Some(run.start).filter(|id| self.cg.node(*id).is_some()) + }; + if let Some(seg) = sip.segments.last_mut() { + seg.base = last_base; + } + + let id = self.bind_stack_identity(chain_id, &sip.segments, bound_ids); + let mut segments = sip.segments; + + // An entrypoint gitbutler/* ref may NAME a run (absorbing its commits in + // legacy bare-checkout shapes) but never rests as an empty segment. + segments.retain(|seg| { + seg.commits_outside.is_some() + || !seg.commits.is_empty() + || !seg.ref_name().is_some_and(in_gitbutler_namespace) + }); + if segments.is_empty() { + if li.is_some() { + self.emit_collapsed_chain_last_trace(&branch_names, &mut segments); + } + if segments.is_empty() { + // Fully empty stacks are removed โ€” a parent whose run was consumed + // elsewhere contributes nothing. + return None; + } + } + Some(Stack::from_base_and_segments_raw(segments, id)) + } + + /// PHASE 1: claim the run's metadata chain (if any) and dedupe its branch + /// list โ€” metadata written mid-operation can list a branch twice; only the + /// first occurrence shapes the stack. + fn claim_chain( + &self, + run: &Run, + claimed: &mut [bool], + bound_ids: &mut HashSet, + ) -> ( + Option, + Option, + Vec<&'a gix::refs::FullName>, + ) { + let Some((li, meta_stack)) = run.chain.map(|li| (li, self.in_ws_stacks[li])) else { + return (None, None, Vec::new()); + }; + claimed[li] = true; + let mut names: Vec<&gix::refs::FullName> = Vec::new(); + for b in &meta_stack.branches { + if !names.iter().any(|n| **n == b.ref_name) { + names.push(&b.ref_name); + } + } + bound_ids.insert(meta_stack.id); + (Some(li), Some(meta_stack.id), names) + } + + /// PHASE 2: the chain's empty branches, in metadata order โ€” they splice in + /// wherever the walk crosses their anchor. + fn pending_empties( + &self, + branch_names: &[&'a gix::refs::FullName], + ) -> Vec<&'a gix::refs::FullName> { + branch_names + .iter() + .filter(|n| { + self.idx + .pos_by_name + .get(n.as_ref()) + .is_some_and(|(facts, _)| facts.names_segment && facts.names_empty_segment) + }) + .copied() + .collect() + } + + /// PHASE 3: walk the run commit by commit. This chain's (or chainless) + /// naming refs cut segments; another STACK's naming ref or the workspace + /// bound ends the territory. Returns the base the walk stopped on โ€” + /// `None` when it ended on another stack's territory (per the sibling-base rule + /// (see `hide_bases_on_sibling_territory`), resting on a sibling shows no base). + #[expect(clippy::too_many_arguments)] + fn walk_run( + &self, + run: &Run, + li: Option, + branch_names: &[&'a gix::refs::FullName], + claimed: &[bool], + unclaimed_empties_at: &mut HashMap>, + pending: &mut Vec<&'a gix::refs::FullName>, + bound_rest: &mut Vec<&'a gix::refs::FullName>, + sip: &mut SegmentsInProgress<'a>, + ) -> Option { + let run_start = run.owns_run.then_some(run.start); + let mut last_base = None; + let mut cursor = run_start; + while let Some(id) = cursor { + if self.stops_at_bound(id, run_start, li, unclaimed_empties_at, bound_rest) { + last_base = Some(id); + break; + } + let Some(node) = self.cg.node(id) else { break }; + self.splice_empties_at(id, run_start, li, pending, unclaimed_empties_at, sip); + self.flush_for_entrypoint(id, run_start, sip); + let detached_start = + self.anchor.ad_hoc() && li.is_none() && self.detached && Some(id) == run_start; + match self.segment_cut_at(id, run_start, li, branch_names, claimed, &sip.emitted) { + SegmentCut::ForeignTerritory => { + // (A run's START always belongs to its stack, even when named by + // a branch whose chain claimed elsewhere โ€” metadata written + // mid-move splits a chain across two parents.) + // Another STACK's territory (a chain that got its own stack): per the + // sibling-base rule (see `hide_bases_on_sibling_territory`), resting on + // a sibling shows no base. + last_base = None; + break; + } + SegmentCut::Named { + name, + projected_outside, + } => { + sip.flush_current(); + sip.emitted.insert(name.as_ref()); + let mut seg = named_segment(name.clone(), Some(id), self.ctx); + if let Some(outside) = projected_outside { + seg.name_projected_from_outside = true; + if !outside.is_empty() { + seg.commits_outside = Some(outside.clone()); + } + } + sip.current = Some(seg); + } + SegmentCut::Anonymous => { + if sip.current.is_none() { + sip.current = Some(StackSegment::default()); + } + } + } + let appended_inside = self.append_commit(node, id, li, detached_start, sip); + last_base = next_parent(self.cg, id, &self.reaches_entrypoint); + cursor = last_base; + // A commit whose raw parents were never walked ends the run early โ€” + // the traversal's limit, worn by the last collected commit. + if appended_inside + && cursor.is_none() + && !node.parent_ids.is_empty() + && !node.flags.contains(crate::CommitFlags::ShallowBoundary) + && let Some(commit) = sip.current.as_mut().and_then(|seg| seg.commits.last_mut()) + { + commit.flags |= crate::workspace::StackCommitFlags::EarlyEnd; + } + } + last_base + } + + /// The walk stops when it reaches the workspace bound. Chainless empties + /// resting ON the bound settle at this stack's bottom, after its own + /// leftovers โ€” a stack whose own run start IS the bound has first claim on them. + /// (An AD-HOC stack STARTING on the bound walks right through โ€” a checkout's + /// own segment is always shown.) + fn stops_at_bound( + &self, + id: gix::ObjectId, + run_start: Option, + li: Option, + unclaimed_empties_at: &mut HashMap>, + bound_rest: &mut Vec<&'a gix::refs::FullName>, + ) -> bool { + if Some(id) != self.lower_bound { + return false; + } + let walks_through = self.anchor.ad_hoc() + && li.is_none() + && entry_walks_through( + self.ctx, + self.layout, + &self.idx.pos_by_name, + self.entry_ref, + id, + run_start, + ); + if walks_through { + return false; + } + if !self.bound_run_exists || run_start == Some(id) { + *bound_rest = unclaimed_empties_at.remove(&id).unwrap_or_default(); + if self.anchor.ad_hoc() && li.is_none() && Some(id) == run_start { + retain_ordered_after_entry(bound_rest, self.ctx, self.entry_ref); + } + } + true + } + + /// The entrypoint commit starts its own segment even without a naming ref. + fn flush_for_entrypoint( + &self, + id: gix::ObjectId, + run_start: Option, + sip: &mut SegmentsInProgress<'_>, + ) { + if Some(id) == self.effective_entrypoint + && Some(id) != run_start + && sip + .current + .as_ref() + .is_some_and(|seg| !seg.commits.is_empty()) + && sip.current.as_ref().and_then(|seg| seg.ref_name()) + != self.entry_ref.map(|r| r.as_ref()) + { + sip.flush_current(); + } + } + + /// Add the walked commit to the current segment. Commits the workspace + /// doesn't own (an advanced tip that left the workspace) ride OUTSIDE the + /// segment until workspace territory starts; returns whether the commit + /// landed inside. + fn append_commit( + &self, + node: &crate::Commit, + id: gix::ObjectId, + li: Option, + detached_start: bool, + sip: &mut SegmentsInProgress<'_>, + ) -> bool { + let Some(seg) = sip.current.as_mut() else { + return false; + }; + if li.is_some() + && !node.flags.contains(crate::CommitFlags::InWorkspace) + && seg.commits.is_empty() + { + let mut commit = StackCommit::from_graph_commit(node); + strip_structural_refs(&mut commit, seg.ref_name(), &self.idx.names_empty); + seg.commits_outside + .get_or_insert_with(Vec::new) + .push(commit); + return false; + } + let mut commit = StackCommit::from_graph_commit(node); + strip_structural_refs(&mut commit, seg.ref_name(), &self.idx.names_empty); + // A detached checkout hands its name back to the commit LAST โ€” after any + // tags that already rode there. + if detached_start + && let Some(&name) = self.idx.naming_at.get(&id) + && let Some(i) = commit + .refs + .iter() + .position(|ri| ri.ref_name.as_ref() == name.as_ref()) + { + let ri = commit.refs.remove(i); + commit.refs.push(ri); + } + seg.commits.push(commit); + true + } + + /// PHASE 3a: empties anchored on THIS commit splice in before its segment. + fn splice_empties_at( + &self, + id: gix::ObjectId, + run_start: Option, + li: Option, + pending: &mut Vec<&'a gix::refs::FullName>, + unclaimed_empties_at: &mut HashMap>, + sip: &mut SegmentsInProgress<'a>, + ) { + let anchor_of = + |name: &gix::refs::FullNameRef| self.idx.pos_by_name.get(name).and_then(|&(_, on)| on); + let mut here: Vec<&gix::refs::FullName> = Vec::new(); + pending.retain(|n| { + if anchor_of(n.as_ref()) == Some(id) { + here.push(n); + false + } else { + true + } + }); + if (Some(id) == run_start || !self.owned_run_starts.contains(&id)) + && let Some(more) = unclaimed_empties_at.remove(&id) + { + here.extend(more); + } + // An ad-hoc view starts AT the entrypoint ref: everything ABOVE the + // entry in its persisted order is out of view; the rest splice in order. + if self.anchor.ad_hoc() && li.is_none() && Some(id) == run_start { + retain_ordered_after_entry(&mut here, self.ctx, self.entry_ref); + } + if here.is_empty() { + return; + } + sip.flush_current(); + // A commit with no naming ref of its own takes the bottom-most + // empty anchored on it as its segment name and is absorbed by it; + // the rest stay empty above it. + let absorbs = self + .idx + .naming_at + .get(&id) + .filter(|n| { + li.is_none() || n.category() != Some(gix::reference::Category::RemoteBranch) + }) + .is_none() + && !self.idx.projected_at.contains_key(&id); + let absorber = if absorbs { here.pop() } else { None }; + for n in here { + sip.emitted.insert(n.as_ref()); + // An empty segment points at no commit of its own โ€” `commit_id` + // is the branch's OWN tip, and consumers (e.g. squash's + // empty-target refusal) rely on None meaning empty. + sip.segments + .push(named_segment((*n).clone(), None, self.ctx)); + } + if let Some(n) = absorber { + sip.emitted.insert(n.as_ref()); + sip.current = Some(named_segment((*n).clone(), Some(id), self.ctx)); + } + } + + /// PHASE 3b: decide what names this commit โ€” a segment cut, foreign + /// territory, or nothing. + fn segment_cut_at<'s>( + &'s self, + id: gix::ObjectId, + run_start: Option, + li: Option, + branch_names: &[&'a gix::refs::FullName], + claimed: &[bool], + emitted: &HashSet<&gix::refs::FullNameRef>, + ) -> SegmentCut<'a, 's> { + let mut direct = self.idx.naming_at.get(&id).copied(); + // Remote names only speak in CHAINLESS stacks; a chain-claimed run keeps + // its commits under the chain's own segments. + if li.is_some() + && direct.is_some_and(|n| n.category() == Some(gix::reference::Category::RemoteBranch)) + { + direct = None; + } + // A name this stack already emitted never names a second segment. + if direct.is_some_and(|n| emitted.contains(n.as_ref())) { + direct = None; + } + // A DETACHED head doesn't speak for the branches at its commit: the + // run starts anonymous and they ride along. + let detached_start = + self.anchor.ad_hoc() && li.is_none() && self.detached && Some(id) == run_start; + if detached_start { + direct = None; + } else if direct.is_none() && Some(id) == run_start { + // A claiming chain's own positioned branch names its run start even + // when the position was too ambiguous to cut runs generally. + direct = branch_names + .iter() + .find(|n| { + self.idx + .pos_by_name + .get(n.as_ref()) + .is_some_and(|&(facts, on)| { + facts.names_segment && !facts.names_empty_segment && on == Some(id) + }) + }) + .copied(); + } + let projected = (direct.is_none() && !detached_start) + .then(|| self.idx.projected_at.get(&id)) + .flatten() + .filter(|(name, _)| !emitted.contains(name.as_ref())); + match direct.or(projected.map(|(name, _)| *name)) { + Some(name) + if Some(id) != run_start + && self + .chain_of_name + .get(name.as_ref()) + .is_some_and(|&c| Some(c) != li && claimed[c]) => + { + SegmentCut::ForeignTerritory + } + Some(name) => SegmentCut::Named { + name, + projected_outside: projected.map(|(_, outside)| outside), + }, + None => SegmentCut::Anonymous, + } + } + + /// PHASE 4: leftover empties (anchors at or below the bound, or on unwalked + /// commits) settle at the stack's bottom in metadata order. + fn settle_leftover_empties( + &self, + pending: Vec<&'a gix::refs::FullName>, + bound_rest: Vec<&'a gix::refs::FullName>, + sip: &mut SegmentsInProgress<'a>, + ) { + for n in pending.into_iter().chain(bound_rest) { + if !sip.emitted.contains(n.as_ref()) { + // Empty leftovers own no commit โ€” None, not their anchor. + sip.segments.push(named_segment(n.clone(), None, self.ctx)); + } + } + } + + /// PHASE 5: natural stacks still bind their metadata identity when any of + /// their segments carries a metadata stack's branch name (the weightless + /// first-match) โ€” INACTIVE stacks included: identity outlives application. + fn bind_stack_identity( + &self, + chain_id: Option, + segments: &[StackSegment], + bound_ids: &mut HashSet, + ) -> Option { + chain_id + .or_else(|| { + segments + .iter() + .flat_map(|s| { + s.ref_name().into_iter().chain( + s.commits + .iter() + .flat_map(|c| c.refs.iter().map(|ri| ri.ref_name.as_ref())), + ) + }) + .find_map(|name| { + self.ws_meta?.stacks.iter().find_map(|meta| { + (!bound_ids.contains(&meta.id) + && meta.branches.iter().any(|b| b.ref_name.as_ref() == name)) + .then(|| { + bound_ids.insert(meta.id); + meta.id + }) + }) + }) + }) + // The single ad-hoc stack carries the fixed single-branch id; a + // managed-REF workspace (whatever its state) binds from metadata alone. + .or_else(|| { + (self.anchor == ViewAnchor::AdHoc) + .then(but_core::ref_metadata::StackId::single_branch_id) + }) + } + + /// PHASE 6: a CHAIN-claimed run that collapsed to nothing (its walk broke at + /// the bound immediately, e.g. a branch merged upstream whose commit IS the + /// bound) still emits its empty/at-bound branches: this run is the chain's + /// only projection, and a vanished applied chain would be un-applied by the + /// next write. + fn emit_collapsed_chain_last_trace( + &self, + branch_names: &[&gix::refs::FullName], + segments: &mut Vec, + ) { + for n in branch_names { + let pos = self.idx.pos_by_name.get(n.as_ref()); + // Naming rights don't matter here โ€” the chain vanishes otherwise, + // so any empty or at-bound position is trace enough. + let at_bound_or_empty = pos.is_some_and(|&(facts, on)| { + facts.names_empty_segment || (on.is_some() && on == self.lower_bound) + }); + if at_bound_or_empty { + let mut seg = named_segment((*n).clone(), None, self.ctx); + // The anchor is this empty branch's base โ€” operations + // rebuilding the workspace commit fall back to it when + // there is no tip. + seg.base = pos.and_then(|&(_, on)| on); + segments.push(seg); + } + } + } +} + +/// A workspace whose every stack is anonymous, identityless and fully integrated +/// shows nothing at all โ€” there is no un-integrated work to lay out. +fn prune_all_integrated_anonymous(stacks: &mut Vec) { + if !stacks.is_empty() + && stacks.iter().all(|s| s.id.is_none()) + && stacks + .iter() + .flat_map(|s| &s.segments) + .all(|seg| seg.ref_name().is_none()) + && stacks + .iter() + .flat_map(|s| s.segments.iter().flat_map(|seg| &seg.commits)) + .next() + .is_some() + && stacks + .iter() + .flat_map(|s| s.segments.iter().flat_map(|seg| &seg.commits)) + .all(|c| { + c.flags + .contains(crate::workspace::StackCommitFlags::Integrated) + }) + { + stacks.clear(); + } +} + +/// The commit the workspace view anchors on, plus the materialized parents. +/// +/// Without materialized parents, a positioned gitbutler/* ref still pointing at +/// a managed workspace commit defines the workspace view (a branch checkout +/// inside a workspace); otherwise the entrypoint commit is the single run and +/// the ad-hoc discriminator does the rest. +fn resolve_view_anchor( + cg: &CommitGraph, + layout: &crate::ref_layout::RefLayout, + ws_meta: Option<&ref_metadata::Workspace>, + entry_ref: Option<&gix::refs::FullName>, +) -> Option<(gix::ObjectId, Vec)> { + layout + .materialized_ws_parents + .clone() + .map(|m| (m.commit, m.parents)) + .or_else(|| { + // ... and like everywhere else, workspace semantics require the + // metadata to exist; without it the entrypoint's view wins. + ws_meta?; + let on = layout.placements().find_map(|(name, on)| { + (in_gitbutler_namespace(name.as_ref()) && cg.is_managed_ws_commit(on)).then_some(on) + })?; + Some((on, Vec::new())) + }) + .or_else(|| { + // A redone traversal can remember a stale entrypoint commit; the + // entry REF's live position anchors the run instead. + let entry = entry_ref + .map(|r| r.as_ref()) + .or_else(|| cg.entrypoint_ref().map(|r| r.as_ref()))?; + let on = layout.positioned_on(entry)?; + Some((on, Vec::new())) + }) + .or_else(|| Some((cg.entrypoint()?, Vec::new()))) +} + +/// A workspace REF with metadata but NO in-workspace stacks and no managed +/// commit is a defined-but-empty workspace: a checkout of an unapplied branch +/// doesn't become an ad-hoc stack inside it. +fn entry_is_unapplied_branch( + cg: &CommitGraph, + ws_commit: gix::ObjectId, + ws_meta: Option<&ref_metadata::Workspace>, + in_ws_stacks: &[&ref_metadata::WorkspaceStack], + has_managed_ref: bool, + entry_ref: Option<&gix::refs::FullName>, +) -> bool { + has_managed_ref + && !cg.is_managed_ws_commit(ws_commit) + && ws_meta.is_some_and(|meta| !meta.stacks.is_empty()) + && in_ws_stacks.is_empty() + && entry_ref + .or_else(|| cg.entrypoint_ref()) + .zip(ws_meta) + .is_some_and(|(entry, meta)| { + meta.stacks.iter().any(|stack| { + !stack.is_in_workspace() + && stack + .branches + .iter() + .any(|b| b.ref_name.as_ref() == entry.as_ref()) + }) + }) +} + +/// The layout read three ways: positioned refs by name, run-naming refs by commit, +/// empty-segment names, and out-of-workspace name projections. +struct LayoutIndexes<'a> { + pos_by_name: PosByName<'a>, + naming_at: HashMap, + names_empty: HashSet<&'a gix::refs::FullNameRef>, + /// A naming ref positioned OUTSIDE the workspace projects onto its first + /// in-workspace first-parent ancestor: the segment there takes its name, and the + /// outside prefix rides along as commits_outside. + projected_at: HashMap)>, +} + +/// The `refs/heads/gitbutler/` namespace: refs GitButler creates for itself (the +/// workspace ref, targets) rather than the user's branches. +fn in_gitbutler_namespace(name: &gix::refs::FullNameRef) -> bool { + name.as_bstr().starts_with(b"refs/heads/gitbutler/") +} + +/// Implementation refs never shape user-visible stacks: they neither name segments +/// nor ride on commits. +fn is_implementation_ref(cg: &CommitGraph, name: &gix::refs::FullNameRef) -> bool { + in_gitbutler_namespace(name) && cg.entrypoint_ref().map(|r| r.as_ref()) != Some(name) +} + +fn index_layout<'a>( + cg: &CommitGraph, + layout: &'a crate::ref_layout::RefLayout, + anchor: ViewAnchor, +) -> LayoutIndexes<'a> { + let placed: HashMap<&gix::refs::FullNameRef, gix::ObjectId> = layout + .placements() + .map(|(name, on)| (name.as_ref(), on)) + .collect(); + let mut pos_by_name = PosByName::new(); + for (name, facts) in &layout.facts { + pos_by_name.insert(name.as_ref(), (facts, placed.get(name.as_ref()).copied())); + } + let mut naming_at = HashMap::::new(); + let mut names_empty = HashSet::<&gix::refs::FullNameRef>::new(); + let mut projected_at = + HashMap::)>::new(); + let in_ws = |id: gix::ObjectId| { + cg.node(id) + .is_some_and(|n| n.flags.contains(crate::CommitFlags::InWorkspace)) + }; + for (name, facts) in layout + .facts + .iter() + .filter(|(name, facts)| facts.names_segment && !is_implementation_ref(cg, name.as_ref())) + { + if facts.names_empty_segment { + names_empty.insert(name.as_ref()); + continue; + } + let Some(pos_on) = placed.get(name.as_ref()).copied() else { + continue; + }; + if name.category() == Some(gix::reference::Category::RemoteBranch) + && anchor.managed_commit() + { + // Remote positions never carve a WORKSPACE stack's runs โ€” locals name + // segments, remotes only pair up as sidebands. An ad-hoc stack without + // local names does read them. + continue; + } + if in_ws(pos_on) || name.category() != Some(gix::reference::Category::LocalBranch) { + naming_at.insert(pos_on, name); + continue; + } + let mut outside = Vec::new(); + let mut cursor = Some(pos_on); + while let Some(id) = cursor { + let Some(node) = cg.node(id) else { break }; + if node.flags.contains(crate::CommitFlags::InWorkspace) { + projected_at.entry(id).or_insert((name, outside)); + break; + } + if node.flags.contains(crate::CommitFlags::Integrated) { + // Integrated territory below the workspace is not an advanced tip โ€” + // nothing projects from there. + break; + } + let mut commit = StackCommit::from_graph_commit(node); + strip_structural_refs(&mut commit, Some(name.as_ref()), &names_empty); + outside.push(commit); + cursor = cg.all_parent_ids(id).first().copied(); + } + // A ref whose line never reaches the workspace stays where it is โ€” the walk + // may still meet it directly at a run top. + naming_at.insert(pos_on, name); + } + LayoutIndexes { + pos_by_name, + naming_at, + names_empty, + projected_at, + } +} + +/// Ad-hoc entries keep only order-mates at or below the entry in a persisted +/// ad-hoc stack order, sorted to that order; without one, ambiguous same-commit +/// peers don't materialize โ€” only the entry itself does. +fn retain_ordered_after_entry( + names: &mut Vec<&gix::refs::FullName>, + ctx: &GraphContext, + entry_ref: Option<&gix::refs::FullName>, +) { + if let Some((order, ei)) = entry_ref.and_then(|entry| { + ctx.ad_hoc_branch_stack_orders.iter().find_map(|order| { + order + .iter() + .position(|n| n.as_ref() == entry.as_ref()) + .map(|ei| (order, ei)) + }) + }) { + let pos_of = |n: &gix::refs::FullNameRef| order.iter().position(|m| m.as_ref() == n); + names.retain(|n| pos_of(n.as_ref()).is_some_and(|i| i >= ei)); + names.sort_by_key(|n| pos_of(n.as_ref()).unwrap_or(usize::MAX)); + } else { + names.retain(|n| Some(n.as_ref()) == entry_ref.map(|e| e.as_ref())); + } +} + +/// Old ad-hoc semantics at the bound: a run STARTING on the bound with the +/// entrypoint ref really placed there walks through, and a run reaching a bound +/// whose naming branch sits BELOW the entry branch in a persisted ad-hoc stack +/// order includes that territory too. +fn entry_walks_through( + ctx: &GraphContext, + layout: &crate::ref_layout::RefLayout, + pos_by_name: &PosByName<'_>, + entry_ref: Option<&gix::refs::FullName>, + id: gix::ObjectId, + run_start: Option, +) -> bool { + Some(id) == run_start + && entry_ref.is_some_and(|ep| { + pos_by_name.get(ep.as_ref()).is_some_and(|&(facts, on)| { + facts.names_segment && !facts.names_empty_segment && on == Some(id) + }) + }) + || (!ctx.ad_hoc_branch_stack_orders.is_empty() + && entry_ref.is_some_and(|entry| { + // Any positioned ref ON the bound counts โ€” a run continues + // into ordered territory whether its branch is empty or not. + layout + .segment_naming_placements() + .filter(|&(_, on)| on == id) + .any(|(r, _)| { + ctx.ad_hoc_branch_stack_orders.iter().any(|order| { + let ei = order.iter().position(|n| n.as_ref() == entry.as_ref()); + let bi = order.iter().position(|n| n.as_ref() == r.as_ref()); + ei.zip(bi).is_some_and(|(ei, bi)| ei < bi) + }) + }) + })) +} + +/// Whether `to` is reachable from `from` along any parent line. +fn reaches(cg: &CommitGraph, from: gix::ObjectId, to: gix::ObjectId) -> bool { + let mut seen = HashSet::new(); + let mut queue = vec![from]; + while let Some(c) = queue.pop() { + if c == to { + return true; + } + if seen.insert(c) { + queue.extend(cg.all_parent_ids(c)); + } + } + false +} + +/// The next commit on a run: the first parent, except a merge steers toward the +/// entrypoint when a parent line reaches it (`reaches_entrypoint` is the precomputed +/// answer; empty when there is no entrypoint). +fn next_parent( + cg: &CommitGraph, + id: gix::ObjectId, + reaches_entrypoint: &[bool], +) -> Option { + let parents = cg.all_parent_ids(id); + if parents.len() > 1 + && let Some(p) = parents.iter().find(|p| { + cg.index_of(**p) + .is_some_and(|i| reaches_entrypoint.get(i).copied().unwrap_or_default()) + }) + { + return Some(*p); + } + parents.first().copied() +} + +/// Refs consumed as structure stay off the commit: the segment's own naming ref, +/// refs naming empty segments, remote-category refs, and gitbutler/* refs. +fn strip_structural_refs( + commit: &mut StackCommit, + own_name: Option<&gix::refs::FullNameRef>, + names_empty: &HashSet<&gix::refs::FullNameRef>, +) { + commit.refs.retain(|ri| { + let name = ri.ref_name.as_ref(); + use gix::reference::Category; + name.category() != Some(Category::RemoteBranch) + && !in_gitbutler_namespace(name) + && Some(name) != own_name + && !names_empty.contains(name) + }); +} + +pub(crate) fn named_segment( + name: gix::refs::FullName, + commit_id: Option, + ctx: &GraphContext, +) -> StackSegment { + StackSegment { + remote_tracking_ref_name: ctx.remote_tracking.get(&name).cloned(), + ref_info: Some(RefInfo { + ref_name: name, + commit_id, + worktree: None, + }), + ..Default::default() + } +} diff --git a/crates/but-graph/src/projection/frame.rs b/crates/but-graph/src/projection/frame.rs new file mode 100644 index 00000000000..9b196e3eafb --- /dev/null +++ b/crates/but-graph/src/projection/frame.rs @@ -0,0 +1,618 @@ +//! The workspace frame derived from the arena, its stored layout and the context: +//! workspace kind, entrypoint, target, and the upper/lower bounds. + +use std::collections::HashSet; + +use gix::ObjectId; + +use crate::workspace::{GraphContext, WorkspaceKind}; +use crate::{CommitFlags, CommitGraph}; + +/// The commit/name-addressed workspace frame. +#[derive(Debug)] +pub(crate) struct WorkspaceFrame { + /// What kind of workspace the entrypoint sees, `Managed`'s ref info included. + pub kind: crate::workspace::WorkspaceKind, + /// The workspace metadata, in managed views. + pub metadata: Option, + /// The commit the view's tip rests on: the managed workspace commit, or the + /// checked-out tip. + pub tip_commit: Option, + /// The tip's naming โ€” the workspace ref or the checked-out branch. + pub tip_ref_info: Option, + /// The entrypoint ref, if any. + pub entry_ref: Option, + /// The commit the entrypoint rests on. + pub entry_commit: Option, + /// Whether the entrypoint is a checkout INSIDE the managed workspace (a branch + /// or commit within its reach, distinct from the tip). + pub entry_inside: bool, + /// The workspace's lowest base. + pub lower_bound: Option, + /// The name of the branch resting on the bound, if any. + pub lower_bound_ref_name: Option, + /// The target's name and resolved tip commit. + pub target_ref: Option, + /// The remembered (or seeded) target commit. + pub target_commit: Option, + /// Target-remote tip commits beyond the target ref โ€” extra target context. + pub integrated_tip_commits: Vec, +} + +impl WorkspaceFrame { + pub(crate) fn derive(cg: &CommitGraph, ctx: &GraphContext) -> Option { + let naming = naming_commits(cg); + let seeds = &cg.seeds; + let entry_seed = seeds.iter().find(|s| s.is_entrypoint); + let detached = entry_seed.is_some_and(|s| s.is_detached); + // The seed carries the ref the entry was REQUESTED as; the arena's + // entrypoint ref is the segment that OWNED the commit โ€” a fallback. + let entry_ref = (!detached) + .then(|| { + entry_seed + .and_then(|s| s.ref_name.clone()) + .or_else(|| cg.entrypoint_ref().cloned()) + }) + .flatten(); + let entry_commit = entry_ref + .as_ref() + .and_then(|name| cg.layout()?.positioned_on(name.as_ref())) + .or_else(|| cg.entrypoint()) + .or_else(|| entry_seed.map(|s| s.id)); + // The builder resolved the entrypoint through its empty chain already โ€” + // the verdict rides on the context (None when the chain forks or + // dead-ends: the ref must point to its commit unambiguously). + let entry_flags = ctx + .entry_resolved_commit + .or(entry_commit) + .filter(|_| ctx.entry_resolved_commit.is_some()) + .and_then(|id| cg.node(id)) + .map(|n| n.flags) + .unwrap_or_default(); + + let seed_ws = seeds.iter().find_map(|s| match (&s.ref_name, &s.metadata) { + (Some(name), Some(crate::SegmentMetadata::Workspace(meta))) => { + Some(crate::workspace::WorkspaceMeta { + ref_name: name.clone(), + metadata: meta.clone(), + }) + } + _ => None, + }); + let ws = ctx.workspace_meta.clone().or(seed_ws); + let ws = ws.as_ref(); + // The workspace rests where its ref points โ€” the layout position is a + // PLACEMENT (an advanced ws ref gets placed as an empty over the base). + let ws_pos = ws.and_then(|wm| { + let name = &wm.ref_name; + seeds + .iter() + .find(|s| { + matches!(s.role, crate::walk::SeedRole::Workspace) + && s.ref_name.as_ref() == Some(name) + }) + .map(|s| s.id) + .filter(|id| cg.node(*id).is_some()) + .or_else(|| cg.ref_target(name)) + .or_else(|| position_of(cg, name.as_ref())) + }); + let (mut kind, mut metadata, mut tip_commit, mut tip_ref, entry_inside) = + classify_kind(ctx, ws, ws_pos, &entry_ref, entry_commit, entry_flags); + + let Targets { + configured_target_ref, + configured_target_commit, + mut target_ref, + mut target_commit, + integrated_tip_commits, + } = resolve_targets(cg, ctx, &naming); + + // A non-managed view without a configured target auto-adopts the tip's remote + // as its target โ€” decided HERE, so the bound computation below stays a pure read. + if !kind.has_managed_ref() && target_ref.is_none() { + target_ref = tip_ref + .as_ref() + .and_then(|name| ctx.remote_tracking.get(name)) + .and_then(|remote| { + ctx.branch_details + .get(tip_ref.as_ref()?)? + .remote_walk_tip + .map(|tip| FrameTarget { + name: remote.clone(), + tip: Some(tip), + }) + }); + } + let mut lower_bound = compute_lower_bound( + cg, + &kind, + tip_commit, + &target_ref, + target_commit, + &integrated_tip_commits, + ); + + // The entrypoint is integrated and has a workspace above it: it gets + // downgraded back to an ad-hoc view if it turns out to be *at* or *below* + // the merge-base, i.e. outside the workspace above. + let entry_is_empty_pos = entry_ref + .as_ref() + .and_then(|name| cg.layout()?.facts_of(name.as_ref())) + .is_some_and(|facts| !facts.names_segment || facts.names_empty_segment); + if integrated_entry_below_bound( + cg, + lower_bound, + entry_commit, + entry_inside, + entry_flags, + entry_is_empty_pos, + ) { + kind = WorkspaceKind::AdHoc; + metadata = None; + tip_commit = entry_commit; + tip_ref = entry_ref.clone(); + // entry_inside stays: the frame keeps its entrypoint through the + // downgrade. + target_ref = configured_target_ref; + target_commit = configured_target_commit; + lower_bound = None; + } + + // A workspace ref pointing at anything but a managed commit misses its + // workspace commit. + if kind.has_managed_ref() + && !tip_commit.is_some_and(|id| cg.is_managed_ws_commit(id)) + && let Some(wm) = ws + { + kind = WorkspaceKind::ManagedMissingWorkspaceCommit { + ref_info: ref_info_for(ctx, &wm.ref_name, ws_pos), + }; + } + + let tip_ref_info = tip_ref.as_ref().map(|name| { + ref_info_for( + ctx, + name, + tip_commit.or_else(|| position_of(cg, name.as_ref())), + ) + }); + let lower_bound_ref_name = lower_bound.and_then(|lb| naming_ref_at(cg, lb)); + + Some(WorkspaceFrame { + kind, + metadata, + tip_commit, + tip_ref_info, + entry_ref, + entry_commit, + entry_inside, + lower_bound, + lower_bound_ref_name, + target_ref, + target_commit, + integrated_tip_commits, + }) + } +} + +/// Kind: HEAD on the ws ref, a checkout INSIDE the workspace's reach, or ad hoc. +fn classify_kind( + ctx: &GraphContext, + ws: Option<&crate::workspace::WorkspaceMeta>, + ws_pos: Option, + entry_ref: &Option, + entry_commit: Option, + entry_flags: CommitFlags, +) -> ( + WorkspaceKind, + Option, + Option, + Option, + bool, +) { + let entry_is_ws = ws.is_some_and(|wm| entry_ref.as_ref() == Some(&wm.ref_name)); + if let Some(wm) = ws.filter(|_| entry_is_ws) { + ( + WorkspaceKind::Managed { + ref_info: ref_info_for(ctx, &wm.ref_name, ws_pos), + }, + Some(wm.metadata.clone()), + ws_pos, + Some(wm.ref_name.clone()), + false, + ) + } else if let Some(wm) = ws.filter(|_| entry_flags.contains(CommitFlags::InWorkspace)) { + ( + WorkspaceKind::Managed { + ref_info: ref_info_for(ctx, &wm.ref_name, ws_pos), + }, + Some(wm.metadata.clone()), + ws_pos, + Some(wm.ref_name.clone()), + true, + ) + } else { + ( + WorkspaceKind::AdHoc, + None, + entry_commit, + entry_ref.clone(), + false, + ) + } +} + +/// The target as the frame sees it: its ref name, and the tip commit when that ref +/// resolves in this graph. +#[derive(Debug, Clone)] +pub struct FrameTarget { + pub name: gix::refs::FullName, + pub tip: Option, +} + +/// The frame's target picture, configured and seeded. +struct Targets { + configured_target_ref: Option, + configured_target_commit: Option, + target_ref: Option, + target_commit: Option, + integrated_tip_commits: Vec, +} + +/// Targets: configured, else seeded target-remote context. Only EXPLICIT +/// seeds count in full; ordinary builds contribute just the extra target. +fn resolve_targets(cg: &CommitGraph, ctx: &GraphContext, naming: &HashSet) -> Targets { + let effective_seeds: Vec = if cg.explicit_seeds { + cg.seeds.clone() + } else { + cg.extra_target + .filter(|extra| cg.node(*extra).is_some()) + .map(|extra| { + crate::walk::Seed::new(extra).with_role(crate::walk::SeedRole::TargetRemote) + }) + .into_iter() + .collect() + }; + let has_ws_seed = effective_seeds + .iter() + .any(|s| matches!(s.metadata, Some(crate::SegmentMetadata::Workspace(_)))); + let target_remote_seeds = || effective_seeds.iter().filter(|s| s.role.is_integrated()); + let configured_target_ref = ctx.project_meta.target_ref.as_ref().and_then(|name| { + let pos = position_of(cg, name.as_ref())?; + Some(FrameTarget { + name: name.clone(), + tip: Some(pos), + }) + }); + let target_commit_valid = + |id: ObjectId| cg.node(id).is_some() && is_segment_start(cg, naming, id); + let configured_target_commit = ctx + .project_meta + .target_commit_id + .filter(|id| target_commit_valid(*id)); + let target_ref = configured_target_ref.clone().or_else(|| { + if has_ws_seed { + return None; + } + target_remote_seeds() + .filter_map(|s| s.ref_name.clone()) + .find_map(|name| { + let pos = position_of(cg, name.as_ref())?; + Some(FrameTarget { + name, + tip: Some(pos), + }) + }) + }); + let target_points_to = |target: &Option, id: ObjectId| { + target.as_ref().is_some_and(|t| t.tip == Some(id)) + }; + let target_commit = configured_target_commit.or_else(|| { + target_remote_seeds() + .map(|s| s.id) + .filter(|id| target_commit_valid(*id)) + .filter(|id| !target_points_to(&target_ref, *id)) + .max_by_key(|id| cg.generation_of(*id).unwrap_or_default()) + }); + let integrated_tip_commits: Vec = target_remote_seeds() + .map(|s| s.id) + .filter(|id| target_commit_valid(*id)) + .filter(|id| !target_points_to(&target_ref, *id)) + .fold(Vec::new(), |mut acc, id| { + if !acc.contains(&id) { + acc.push(id); + } + acc + }); + Targets { + configured_target_ref, + configured_target_commit, + target_ref, + target_commit, + integrated_tip_commits, + } +} + +/// The lowest base: merge-base folds over the view's tips and target context. +fn compute_lower_bound( + cg: &CommitGraph, + kind: &WorkspaceKind, + tip_commit: Option, + target_ref: &Option, + target_commit: Option, + integrated_tip_commits: &[ObjectId], +) -> Option { + let base_context = |target_ref: &Option, target_commit: &Option| { + target_ref + .iter() + .filter_map(|t| t.tip) + .chain(*target_commit) + .chain(integrated_tip_commits.iter().copied()) + .collect::>() + }; + if kind.has_managed_ref() { + // The stack tips: a managed commit's parents, or โ€” when the workspace + // commit is missing โ€” the metadata chains' positions. + let tips: Vec = if tip_commit.is_some_and(|ws| cg.is_managed_ws_commit(ws)) { + // A stack can sit outside the merge's cone entirely โ€” its chain + // then anchors FROM the workspace (a non-owning splice), and the + // base must still lie below it. + let anchors: Vec = cg + .layout() + .map(|l| { + l.empty_chain_anchors + .iter() + .filter(|anchor| !anchor.joins_owning_chain) + .map(|anchor| anchor.commit) + .collect() + }) + .unwrap_or_default(); + let mut tips = tip_commit + .map(|ws| cg.all_parent_ids(ws)) + .unwrap_or_default(); + for anchor in anchors { + if !tips.contains(&anchor) { + tips.push(anchor); + } + } + tips + } else { + // A commitless workspace segment only gets FRESH connections for + // spliced empty chains โ€” chains with real segments hang off + // history, not the workspace ref. Those splices are the tips. + cg.layout() + .map(|l| { + l.empty_chain_anchors + .iter() + .map(|anchor| anchor.commit) + .collect() + }) + .unwrap_or_default() + }; + // Chains of nothing but archived branches leave no positions; the + // workspace ref's own anchor stands in. + let tips = if tips.is_empty() && !tip_commit.is_some_and(|ws| cg.is_managed_ws_commit(ws)) { + tip_commit.into_iter().collect() + } else { + tips + }; + let (base, count) = best_effort_base( + cg, + tips.iter() + .copied() + .chain(base_context(target_ref, &target_commit)), + ); + // A MANAGED merge can't rest on itself; a missing-commit workspace + // legitimately sits right on its base. + let tip_is_managed = tip_commit.is_some_and(|ws| cg.is_managed_ws_commit(ws)); + base.filter(|b| count >= 2 && (!tip_is_managed || Some(*b) != tip_commit)) + .or_else(|| { + // target not available? Try the base of the workspace itself. + (tips.len() > 1) + .then(|| best_effort_base(cg, tips.iter().copied()).0) + .flatten() + }) + } else { + let context = base_context(target_ref, &target_commit); + if context.is_empty() { + None + } else { + // In single-branch mode a checkout directly reachable from its + // target keeps the base โ€” the view is just empty then. + best_effort_base(cg, tip_commit.into_iter().chain(context)).0 + } + } +} + +/// Whether an integrated entrypoint inside the workspace sits at or below the +/// lower bound, i.e. outside the workspace above. +fn integrated_entry_below_bound( + cg: &CommitGraph, + lower_bound: Option, + entry_commit: Option, + entry_inside: bool, + entry_flags: CommitFlags, + entry_is_empty_pos: bool, +) -> bool { + let Some((bound, entry)) = lower_bound + .zip(entry_commit) + .filter(|_| entry_inside && entry_flags.contains(CommitFlags::Integrated)) + else { + return false; + }; + // An EMPTY entry ABOVE the bound commit is its own place โ€” only a + // real entry sitting ON the bound is 'at' it. + (entry == bound && !entry_is_empty_pos) || !reaches_along_first_parent(cg, entry, bound) +} + +/// The ref info for `name` resting on `commit`, worktree details included. +fn ref_info_for( + ctx: &GraphContext, + name: &gix::refs::FullName, + commit: Option, +) -> crate::RefInfo { + crate::RefInfo { + ref_name: name.clone(), + commit_id: commit, + worktree: ctx + .branch_details + .get(name) + .and_then(|d| d.worktree.clone()), + } +} + +/// A commit STARTS a segment when a positioned ref names it, when it fans out or +/// tips (child count differs from one), or when its only child reaches it through +/// a non-first-parent edge โ€” the walk's segmentation marks. +fn is_segment_start(cg: &CommitGraph, naming: &HashSet, id: ObjectId) -> bool { + if naming.contains(&id) || cg.is_managed_ws_commit(id) { + return true; + } + // Every traversal seed starts its own segment. + if cg.seeds.iter().any(|s| s.id == id) || cg.extra_target == Some(id) { + return true; + } + let Some(idx) = cg.index_of(id) else { + return true; + }; + let children = cg.children_at(idx); + let [child] = children else { return true }; + let child = cg.node_at(*child); + if cg.all_parent_ids(child.id).first() != Some(&id) { + return true; + } + // Flag transitions mark boundaries too: workspace and integration territory + // starts a fresh segment. + let relevant = CommitFlags::InWorkspace | CommitFlags::Integrated; + let own = cg.node_at(idx).flags.intersection(relevant); + own != child.flags.intersection(relevant) +} + +/// The layout position of `name`, if positioned. +fn position_of(cg: &CommitGraph, name: &gix::refs::FullNameRef) -> Option { + cg.layout()?.positioned_on(name) +} + +/// All commits that carry a segment-naming position. +fn naming_commits(cg: &CommitGraph) -> HashSet { + cg.layout() + .map(|l| l.segment_naming_placements().map(|(_, on)| on).collect()) + .unwrap_or_default() +} + +/// The name of a local branch naming `id`, if any โ€” remotes only when no local does. +fn naming_ref_at(cg: &CommitGraph, id: ObjectId) -> Option { + let layout = cg.layout()?; + let at = |pred: &dyn Fn(&gix::refs::FullName) -> bool| { + layout + .placements() + .find(|(name, on)| { + *on == id + && pred(name) + && layout + .facts_of(name.as_ref()) + .is_some_and(|facts| facts.names_segment && !facts.names_empty_segment) + }) + .map(|(name, _)| name.clone()) + }; + at(&|n| n.category() == Some(gix::reference::Category::LocalBranch)).or_else(|| at(&|_| true)) +} + +/// Whether `to` is reachable from `from` along FIRST parents only. +fn reaches_along_first_parent(cg: &CommitGraph, from: ObjectId, to: ObjectId) -> bool { + let mut seen = HashSet::new(); + let mut cursor = Some(from); + while let Some(id) = cursor.take() { + if id == to { + return true; + } + if !seen.insert(id) { + return false; + } + cursor = cg.all_parent_ids(id).first().copied(); + } + false +} + +/// Fold pairwise merge-bases, best-effort: disjoint inputs keep the previous +/// candidate instead of clearing the bound. +fn best_effort_base( + cg: &CommitGraph, + commits: impl IntoIterator, +) -> (Option, usize) { + let mut count = 0; + let base = commits + .into_iter() + .inspect(|_| count += 1) + .reduce(|base, commit| cg.merge_base(base, commit).unwrap_or(base)); + (base, count) +} + +/// The commits below the framed tip, in traversal order: skip the tip's own run +/// to the next segmentation boundary (entrypoint splits included), then walk, +/// bounded below by the lower bound's generation. This is workspace CONTENT, not +/// a framing fact โ€” derived from the frame's anchors once the frame is settled. +pub(super) fn commits_below_tip(cg: &CommitGraph, frame: &WorkspaceFrame) -> Vec { + let naming = naming_commits(cg); + let tip_ref = frame.tip_ref_info.as_ref().map(|ri| ri.ref_name.clone()); + let bound_generation = frame.lower_bound.and_then(|lb| cg.generation_of(lb)); + let entry_split = frame.entry_inside.then_some(frame.entry_commit).flatten(); + let boundary = |id: ObjectId| is_segment_start(cg, &naming, id) || Some(id) == entry_split; + let mut out = Vec::new(); + let mut seen = HashSet::new(); + let mut runs = std::collections::VecDeque::new(); + // Skip the tip's own run first. + // A tip ref that names no REAL segment holds no commits: nothing + // skips, the territory below starts at the anchor itself. + let tip_is_empty = tip_ref + .as_ref() + .and_then(|name| cg.layout()?.facts_of(name.as_ref())) + .is_some_and(|facts| !facts.names_segment || facts.names_empty_segment); + let mut skip_cursor = frame.tip_commit; + if tip_is_empty { + skip_cursor = None; + runs.extend(frame.tip_commit); + } else if let Some(tip) = frame.tip_commit { + seen.insert(tip); + } + while let Some(id) = skip_cursor.take() { + let parents = cg.all_parent_ids(id); + runs.extend(parents.iter().skip(1).copied()); + match parents.first() { + Some(p) if boundary(*p) => runs.push_back(*p), + Some(p) => skip_cursor = Some(*p), + None => {} + } + } + // Then visit run-wise: a run prunes at its START when it begins beyond + // the bound; commits below the bound inside a surviving run stay. + while let Some(start) = runs.pop_front() { + if seen.contains(&start) { + continue; + } + if bound_generation + .zip(cg.generation_of(start)) + .is_some_and(|(bound, generation)| generation < bound) + { + continue; + } + let mut cursor = Some(start); + while let Some(id) = cursor.take() { + if !seen.insert(id) { + break; + } + if cg.node(id).is_none() { + break; + } + out.push(id); + let parents = cg.all_parent_ids(id); + runs.extend(parents.iter().skip(1).copied()); + match parents.first() { + Some(p) if boundary(*p) => runs.push_back(*p), + Some(p) => cursor = Some(*p), + None => {} + } + } + } + out +} diff --git a/crates/but-graph/src/projection/init.rs b/crates/but-graph/src/projection/init.rs new file mode 100644 index 00000000000..d53e338207a --- /dev/null +++ b/crates/but-graph/src/projection/init.rs @@ -0,0 +1,912 @@ +//! Assemble the [`Workspace`]: the frame decides kind, entrypoint, target and bounds +//! (`frame`), the stacks are collected from the arena and its stored ref layout +//! (`collect`), and this module finishes the picture โ€” name-keyed metadata/worktree +//! sidebands, the entrypoint mark, the target fields, then the pruning passes +//! (archived, integrated, sibling territory). The substrate moves onto the finished +//! workspace: the projection carries the graph it was derived from. + +use std::collections::{BTreeSet, HashMap, HashSet}; + +use anyhow::Context; +use but_core::ref_metadata::{StackId, StackKind::Applied}; +use gix::{ObjectId, refs::Category}; +use tracing::instrument; + +use crate::{ + CommitFlags, CommitGraph, Workspace, + workspace::{ + GraphContext, Stack, StackCommit, StackCommitFlags, StackSegment, TargetCommit, TargetRef, + WorkspaceKind, find_segment_owner_indexes_by_refname, + }, +}; + +/// Project the workspace from the commit graph and the build's context โ€” the +/// application's view over the substrate. +/// +/// Every location of `HEAD` โ€” the entrypoint โ€” projects to SOME workspace. Expensive +/// per-commit reads are spent only on commits the user can interact with. +/// +/// Target commit ids and integrated traversal seeds can extend the +/// workspace to include these commits to define its lowest base. +#[instrument(name = "project_workspace", level = "trace", skip(cg), err(Debug))] +pub(crate) fn project_workspace(cg: CommitGraph, ctx: GraphContext) -> anyhow::Result { + let f = super::frame::WorkspaceFrame::derive(&cg, &ctx) + .context("BUG: the graph must have an entrypoint seed to project a workspace")?; + let commits_below_tip = super::frame::commits_below_tip(&cg, &f); + let tip_ref_name = f.tip_ref_info.as_ref().map(|ri| ri.ref_name.clone()); + let stacks = { + let frame_entry_ref = f + .entry_inside + .then(|| f.entry_ref.clone()) + .flatten() + .or_else(|| tip_ref_name.clone()); + let frame_entry_commit = cg.entrypoint(); + let mut stacks = super::collect::stacks_from_arena( + &cg, + &ctx, + f.lower_bound, + f.metadata.as_ref(), + f.kind.has_managed_ref(), + frame_entry_ref.as_ref(), + frame_entry_commit, + ) + .unwrap_or_else(|| { + // Layoutless graphs โ€” unborn refs โ€” still name one empty stack. + tip_ref_name + .clone() + .map(|name| { + vec![Stack::from_base_and_segments_raw( + vec![super::collect::named_segment(name, None, &ctx)], + Some(StackId::single_branch_id()), + )] + }) + .unwrap_or_default() + }); + enrich_from_branch_details(&mut stacks, &ctx); + mark_entrypoint_segments(&mut stacks, &f, &ctx); + stacks + }; + let mut target_ref = f.target_ref.as_ref().map(|t| TargetRef { + ref_name: t.name.clone(), + tip_commit_id: t.tip, + commits_ahead: 0, + }); + + // One traversal serves both: the ids feed the workspace, their count the target. + let incoming_target_commit_ids = target_ref.as_ref().map(|t| { + t.tip_commit_id + .map(|tip| super::upstream_commits(&cg, tip, f.lower_bound)) + .unwrap_or_default() + }); + if let Some(target) = target_ref.as_mut() { + target.commits_ahead = incoming_target_commit_ids.as_ref().map_or(0, Vec::len); + } + + // Capture the facts queries need once the segment view is gone, in field order. + // The entry's commit is the ref's GIT target โ€” a commitless entry segment + // resolves BELOW its ref, but the entrypoint stays where HEAD pointed. + let entrypoint = Some(crate::workspace::EntrypointInfo { + commit_id: f + .entry_ref + .as_ref() + .and_then(|r| cg.ref_target(r)) + .or_else(|| { + // A commitless entry ref sits on no node โ€” the walk's seed + // still remembers where it pointed. Edited arenas keep their + // stale walk seeds; only a live node counts. + cg.seeds + .iter() + .find(|s| s.is_entrypoint) + .map(|s| s.id) + .filter(|id| cg.node(*id).is_some()) + }) + .or(f.entry_commit) + // Seam-built arenas carry no walk artifacts at all; an ad-hoc + // view's entry IS its tip. + .or(f.tip_commit), + ref_name: f.entry_ref.clone(), + is_ws_tip: !f.entry_inside, + }); + let target_ref_commit_id = target_ref.as_ref().and_then(|t| t.tip_commit_id); + let effective_target_commit_id = target_ref_commit_id + .or(f.target_commit) + .or_else(|| f.integrated_tip_commits.first().copied()); + let has_multiple_worktrees = { + let mut kinds = cg.ref_worktrees().map(|wt| &wt.kind).chain( + ctx.branch_details + .values() + .filter_map(|d| d.worktree.as_ref().map(|wt| &wt.kind)), + ); + let first = kinds.next(); + first.is_some_and(|first| kinds.any(|kind| kind != first)) + }; + let target_commit = f.target_commit.map(|commit_id| TargetCommit { commit_id }); + let mut ws = Workspace { + kind: f.kind, + stacks, + lower_bound: f.lower_bound, + target_ref, + target_commit, + metadata: f.metadata, + commit_graph: crate::CommitGraph::default(), + ctx, + tip_ref_info: f.tip_ref_info, + tip_commit_id: f.tip_commit, + lower_bound_ref_name: f.lower_bound_ref_name, + entrypoint, + target_ref_commit_id, + effective_target_commit_id, + commits_below_tip, + incoming_target_commit_ids, + has_multiple_worktrees, + }; + + ws.prune_archived_segments(); + { + // Prune inputs at commit granularity: the advanced-upstream signal, the + // target's own top commit (the commit its ref really names, if any), and + // the tip's name for ad-hoc keeps. + let upstream_advanced_past_target = !f.integrated_tip_commits.is_empty(); + let target_top_anchor = ws + .target_commit + .as_ref() + .map(|tc| tc.commit_id) + .or_else(|| { + let name = &ws.target_ref.as_ref()?.ref_name; + let layout = cg.layout()?; + let facts = layout.facts_of(name.as_ref())?; + (facts.names_segment && !facts.names_empty_segment) + .then(|| layout.positioned_on(name.as_ref())) + .flatten() + }); + ws.prune_integrated_segments( + &cg, + upstream_advanced_past_target, + target_top_anchor, + tip_ref_name.as_ref().map(|r| r.as_ref()), + ); + } + ws.mark_remote_reachability(&cg)?; + ws.add_commits_on_remote(&cg); + ws.truncate_single_stack_to_match_base(); + debug_assert_applied_stacks_projected(&cg, &ws); + // The projection is done: the substrate moves onto the workspace. + ws.commit_graph = cg; + Ok(ws) +} + +/// Name-keyed enrichment from the builder's capture: metadata sidebands and worktree +/// info ride on names, not structure. +fn enrich_from_branch_details(stacks: &mut [Stack], ctx: &GraphContext) { + for seg in stacks.iter_mut().flat_map(|s| &mut s.segments) { + let Some(details) = seg.ref_name().and_then(|name| ctx.branch_details.get(name)) else { + continue; + }; + seg.metadata = details.metadata.clone(); + if let Some(ri) = seg.ref_info.as_mut() { + ri.worktree = details.worktree.clone(); + } + } +} + +/// The entrypoint mark mirrors the frame's entrypoint: its named segment, else the +/// first segment holding the entrypoint commit. Only managed workspaces mark โ€” ad-hoc +/// views ARE their entrypoint. +fn mark_entrypoint_segments( + stacks: &mut [Stack], + f: &super::frame::WorkspaceFrame, + ctx: &GraphContext, +) { + if !(f.entry_inside && f.kind.has_managed_ref()) { + return; + } + let ep_name = f.entry_ref.as_ref().map(|r| r.as_ref()); + let ep_commit = ctx.entry_resolved_commit.or(f.entry_commit); + let by_name: Vec<&mut StackSegment> = stacks + .iter_mut() + .flat_map(|s| &mut s.segments) + .filter(|seg| ep_name.is_some() && seg.ref_name() == ep_name) + .collect(); + if by_name.is_empty() { + for seg in stacks.iter_mut().flat_map(|s| &mut s.segments) { + if ep_commit.is_some_and(|id| seg.commits.iter().any(|c| c.id == id)) { + seg.is_entrypoint = true; + } + } + } else { + for seg in by_name { + seg.is_entrypoint = true; + } + } +} + +/// Test-build tripwire for the most dangerous projection failure: an APPLIED metadata stack +/// silently disappearing from `stacks`. Operations rebuild the workspace merge and metadata +/// heads from the projection, so a vanished stack does not just render wrong โ€” it gets +/// PERSISTED by the next write. Scoped to managed workspaces; a stack counts only when at +/// least one non-archived branch of it actually names a segment in this graph. +#[cfg(debug_assertions)] +fn debug_assert_applied_stacks_projected(cg: &CommitGraph, ws: &Workspace) { + use but_core::ref_metadata::StackKind::Applied; + if !matches!(ws.kind, WorkspaceKind::Managed { .. }) { + return; + } + let Some(meta) = ws.metadata.as_ref() else { + return; + }; + let projected: std::collections::HashSet<_> = ws + .stacks + .iter() + .flat_map(|s| s.segments.iter()) + .filter_map(|s| s.ref_name().map(|r| r.to_owned())) + .collect(); + // Only branches REACHABLE from the workspace tip count: metadata is the DESIRED state + // and legitimately runs ahead of the graph mid-operation (apply writes metadata before + // the workspace merge is rebuilt). The failure class is a branch that IS in the + // workspace's reach and still lost its stack. Reachability is seeded from the tip and + // everything below it โ€” the below-walk stops at the bound, the parent walk continues. + let mut reachable = std::collections::HashSet::new(); + let mut queue: Vec<_> = ws + .tip_commit_id + .into_iter() + .chain(ws.commits_below_tip.iter().copied()) + .collect(); + while let Some(id) = queue.pop() { + if !reachable.insert(id) { + continue; + } + queue.extend(cg.all_parent_ids(id)); + } + let positions = cg.layout(); + for stack in meta.stacks(Applied) { + let represented_branches: Vec<_> = stack + .branches + .iter() + .filter(|b| !b.archived) + .filter(|b| { + let Some(facts) = positions.and_then(|layout| layout.facts_of(b.ref_name.as_ref())) + else { + return false; + }; + if !facts.names_segment { + return false; + } + let Some(on) = + positions.and_then(|layout| layout.positioned_on(b.ref_name.as_ref())) + else { + return false; + }; + reachable.contains(&on) + // A stack anchored on INTEGRATED territory away from the workspace + // lower bound is deliberately not overzealously materialized; one + // resting ON the lower bound (or holding live commits, or empty) + // must keep its stack. + && (facts.names_empty_segment + || cg.node(on).is_none_or(|n| { + !n.flags.contains(crate::CommitFlags::Integrated) + || Some(on) == ws.lower_bound + })) + }) + .map(|b| b.ref_name.clone()) + .collect(); + if represented_branches.is_empty() { + continue; + } + debug_assert!( + represented_branches + .iter() + .any(|b| projected.contains(b.as_ref())), + "applied metadata stack {:?} (branches {:?}) vanished from the projection โ€” operations writing from this projection would drop the stack on disk", + stack.id, + represented_branches + .iter() + .map(|b| b.as_bstr().to_string()) + .collect::>(), + ); + } +} + +#[cfg(not(debug_assertions))] +fn debug_assert_applied_stacks_projected(_cg: &CommitGraph, _ws: &Workspace) {} + +impl Workspace { + /// Prune segments whose branch is marked archived in workspace metadata โ€” matched by name, + /// that's all we have โ€” top to bottom, and only when everything below them is also empty: + /// a non-empty tail keeps the segment visible regardless of the flag. A stack whose every + /// segment was archived is removed entirely. The flag deliberately stays out of the + /// segment data itself so archived handling remains local to this pass. + fn prune_archived_segments(&mut self) { + let Some(md) = &self.metadata else { + return; + }; + let archived_stack_branches = md.stacks(Applied).flat_map(|s| { + s.branches + .iter() + .filter_map(|s| s.archived.then_some(s.ref_name.as_ref())) + }); + let mut empty_stacks_to_remove = Vec::new(); + for archived_ref_name in archived_stack_branches { + let Some((stack_idx, segment_idx)) = + find_segment_owner_indexes_by_refname(&self.stacks, archived_ref_name) + else { + continue; + }; + let stack = &mut self.stacks[stack_idx]; + let all_downwards_are_empty = stack.segments[segment_idx..] + .iter() + .all(|s| s.commits.is_empty()); + if !all_downwards_are_empty { + continue; + } + stack.segments.truncate(segment_idx); + if stack.segments.is_empty() { + empty_stacks_to_remove.push(stack_idx); + } + } + + empty_stacks_to_remove.sort(); + for stack_idx_to_remove in empty_stacks_to_remove.into_iter().rev() { + let stack = self.stacks.remove(stack_idx_to_remove); + tracing::warn!( + "Pruned stack {stack_id:?} from workspace as all its segments were archived", + stack_id = stack.id + ) + } + } + + /// Remove integrated commits and empty branches at the bottom of each + /// stack, but only those at or below the workspace's target commit. + /// Integrated commits above the target commit are kept until the user advances + /// the target via upstream integration. + // TODO: the per-stack fork point is recomputed on every projection rather than + // stored; persisting it would avoid re-deriving the target trunk each build. + fn prune_integrated_segments( + &mut self, + cg: &crate::CommitGraph, + upstream_advanced_past_target: bool, + target_top_anchor: Option, + ws_tip_ref: Option<&gix::refs::FullNameRef>, + ) { + // Integrated-commit pruning only applies to workspaces tracking an upstream + // target ref; without one, leave the stacks untouched. + if self.target_ref.is_none() { + return; + } + // Extra integrated tips mean upstream advanced past the stored target. Bail only + // if there's no stored target *commit* to bound pruning against. + if self.target_commit.is_none() && upstream_advanced_past_target { + return; + } + + // The territory anchors on the target's top commit; an EMPTY target segment + // anchors on the stored target commit or the target ref's position. + let target_top = target_top_anchor + .or_else(|| self.target_commit.as_ref().map(|tc| tc.commit_id)) + .or_else(|| { + let name = &self.target_ref.as_ref()?.ref_name; + cg.layout()?.positioned_on(name.as_ref()) + }); + let Some(target_top) = target_top else { + return; + }; + let prune_commits = prunable_territory(cg, target_top, upstream_advanced_past_target); + let metadata = self.metadata.as_ref(); + let keep_empty_names = if matches!(self.kind, WorkspaceKind::AdHoc) { + self.ctx + .ad_hoc_branch_stack_orders + .iter() + .flatten() + .map(|ref_name| ref_name.as_ref()) + .chain(ws_tip_ref) + .collect::>() + } else { + BTreeSet::new() + }; + let keep_if_fully_integrated = + upstream_advanced_past_target && !matches!(self.kind, WorkspaceKind::AdHoc); + // The target's LOCAL tracking branch is exempt from integrated pruning when METADATA + // applies it as a stack in a MANAGED workspace: caught up with the target, ALL its + // commits are integrated by definition, so pruning would empty the stack by construction + // and slide its base to the workspace lower bound (`ref_target=M2, base=M1` โ€” an + // incoherent segment). An applied main keeps its commits: its stack IS the base + // indicator. The single-branch view of a checked-out main keeps pruning its integrated + // base as before โ€” membership, and thus the exemption, is metadata-explicit. + let target_local = self + .kind + .has_managed_ref() + .then(|| { + self.target_ref + .as_ref() + .and_then(|tr| { + self.ctx + .remote_tracking + .iter() + .find_map(|(local, r)| (r == &tr.ref_name).then_some(local)) + }) + .map(|local| local.as_ref()) + }) + .flatten(); + for stack in &mut self.stacks { + let is_target_local_stack = + stack.id.is_some() && stack.ref_name().is_some_and(|rn| Some(rn) == target_local); + // Upstream advanced: floor the stack at its fork point but keep a fully-integrated + // tip in managed workspaces so it survives for `integrate_upstream`. Single-branch + // mode keeps the branch shell, but prunes integrated target/base commits from it. + if !is_target_local_stack { + prune_integrated_stack_segments(stack, &prune_commits, keep_if_fully_integrated); + } + } + let orphan_chain_names = orphan_chain_names(metadata, &self.stacks); + for stack in &mut self.stacks { + remove_empty_branches( + stack, + metadata, + &keep_empty_names, + &orphan_chain_names, + matches!(self.kind, WorkspaceKind::AdHoc), + ); + // Pruning moved the stack's bottom; refresh its base to its own fork point with the + // target rather than leaving the pre-prune global merge base. Every branch has its + // own base. + stack.recompute_last_segment_base_from(cg); + } + self.stacks.retain(|stack| !stack.segments.is_empty()); + hide_bases_on_sibling_territory(&mut self.stacks); + } + /// Trace each linked remote down the ARENA and set commit flags to indicate + /// whether a commit in the workspace is reachable from a remote, and how. + fn mark_remote_reachability(&mut self, cg: &crate::CommitGraph) -> anyhow::Result<()> { + // The builder records whether it actually LINKED a remote โ€” an ambiguous + // remote stays deliberately unlinked, so a name lookup won't do. + let remote_refs: Vec<_> = self + .stacks + .iter() + .flat_map(|s| { + s.segments.iter().filter_map(|s| { + let name = s.ref_name()?; + let tip = self.ctx.branch_details.get(name)?.remote_walk_tip?; + s.remote_tracking_ref_name.clone().map(|rn| (rn, tip)) + }) + }) + .collect(); + let remote_named_at = remote_naming_positions(cg); + struct Flagging { + at: ObjectId, + remote: gix::refs::FullName, + } + let mut flaggings = Vec::new(); + for (remote_tracking_ref_name, tip) in remote_refs { + let mut seen = HashSet::new(); + let mut queue = vec![tip]; + while let Some(id) = queue.pop() { + if !seen.insert(id) { + continue; + } + let Some(node) = cg.node(id) else { continue }; + // Stop at non-remote commits, and never 'steal' commits from other + // known remote territory. + let prune = !node.flags.is_remote() + || (id != tip + && remote_named_at + .get(&id) + .is_some_and(|&n| n != &remote_tracking_ref_name)); + if prune { + if !node.flags.is_remote() { + flaggings.push(Flagging { + at: id, + remote: remote_tracking_ref_name.clone(), + }); + } + continue; + } + queue.extend(cg.all_parent_ids(id)); + } + } + for Flagging { at, remote } in flaggings { + for stack in &mut self.stacks { + // The remote's reach into this stack starts where the commit appears. + let Some((first_segment, first_commit_index)) = + stack.segments.iter().enumerate().find_map(|(os_idx, os)| { + os.commits + .iter() + .position(|c| c.id == at) + .map(|ci| (os_idx, ci)) + }) + else { + continue; + }; + let mut first_commit_index = Some(first_commit_index); + for segment in &mut stack.segments[first_segment..] { + let remote_reachable_flags = + if segment.remote_tracking_ref_name.as_ref() == Some(&remote) { + StackCommitFlags::ReachableByMatchingRemote + } else { + StackCommitFlags::empty() + } | StackCommitFlags::ReachableByRemote; + for commit in + &mut segment.commits[first_commit_index.take().unwrap_or_default()..] + { + commit.flags |= remote_reachable_flags; + } + } + // keep looking - other stacks can repeat the commit! + } + } + Ok(()) + } + /// For each local segment that has a linked remote tracking branch, walk the + /// remote side of the ARENA and collect commits that exist on the remote but + /// not locally: + /// - commits that are purely remote (never existed locally or pre-rebase versions), and + /// - non-integrated commits from upper stack segments that are still on the + /// remote (the "branch split" case โ€” a previously combined push left the + /// remote pointing at commits that now belong to branch above it). + fn add_commits_on_remote(&mut self, cg: &crate::CommitGraph) { + let remote_named_at = remote_naming_positions(cg); + let naming = naming_positions(cg); + for stack in &mut self.stacks { + let mut above_commit_ids = HashSet::new(); + for seg_idx in 0..stack.segments.len() { + let Some(tip) = stack.segments[seg_idx] + .ref_name() + .and_then(|name| self.ctx.branch_details.get(name)) + .and_then(|d| d.remote_walk_tip) + else { + // Still accumulate this segment's commits for lower segments. + above_commit_ids.extend(stack.segments[seg_idx].commits.iter().map(|c| c.id)); + continue; + }; + + // Run-wise BFS: collect purely-remote commits, stopping runs at + // local commits or territory another remote-naming position owns. + let mut remote_commits = Vec::new(); + let mut seen_ids = HashSet::new(); + let mut runs = std::collections::VecDeque::from([tip]); + while let Some(run_start) = runs.pop_front() { + let mut cursor = Some(run_start); + while let Some(id) = cursor.take() { + if !seen_ids.insert(id) { + break; + } + let Some(node) = cg.node(id) else { break }; + if !node.flags.is_remote() + || (id != tip + && remote_named_at.get(&id).is_some_and(|&n| { + stack.segments[seg_idx].remote_tracking_ref_name.as_ref() + != Some(n) + })) + { + break; + } + let mut commit = StackCommit::from_graph_commit(node); + // The territory's naming ref is structure, not a rider โ€” + // deduced remotes included. + commit.refs.retain(|ri| { + !naming.contains(&(commit.id, ri.ref_name.as_ref())) + && stack.segments[seg_idx].remote_tracking_ref_name.as_ref() + != Some(&ri.ref_name) + }); + remote_commits.push(commit); + let parents = cg.all_parent_ids(id); + cursor = parents.first().copied(); + runs.extend(parents.into_iter().skip(1)); + } + } + + // First-parent walk: detect non-integrated commits from upper + // stack segments that are still reachable by the remote tracking branch. + if !above_commit_ids.is_empty() { + let mut seen: HashSet<_> = remote_commits.iter().map(|c| c.id).collect(); + let mut extra = Vec::new(); + let mut cursor = Some(tip); + let mut fp_seen = HashSet::new(); + while let Some(id) = cursor.take() { + if !fp_seen.insert(id) { + break; + } + if id != tip && remote_named_at.contains_key(&id) { + break; + } + let Some(node) = cg.node(id) else { break }; + if above_commit_ids.contains(&id) + && !node.flags.contains(CommitFlags::Integrated) + && seen.insert(id) + { + let mut commit = StackCommit::from_graph_commit(node); + commit.refs.retain(|ri| { + !naming.contains(&(commit.id, ri.ref_name.as_ref())) + && stack.segments[seg_idx].remote_tracking_ref_name.as_ref() + != Some(&ri.ref_name) + }); + extra.push(commit); + } + cursor = cg.all_parent_ids(id).first().copied(); + } + remote_commits.extend(extra); + } + + stack.segments[seg_idx].commits_on_remote = remote_commits; + + // Accumulate this segment's commits for lower segments. + above_commit_ids.extend(stack.segments[seg_idx].commits.iter().map(|c| c.id)); + } + } + } + + /// If there is a single stack and the base happens to be itself (which happens if the stack is directly integrated/inline with the target), + /// then empty all commits and segment-related metadata. + fn truncate_single_stack_to_match_base(&mut self) { + if self.stacks.len() != 1 { + return; + } + let Some(stack) = self.stacks.first_mut() else { + return; + }; + let stack_is_base = + stack + .segments + .first() + .zip(self.lower_bound) + .is_some_and(|(segment, base)| + // The stack IS the base when its first commit is the bound commit itself. + segment.commits.first().is_some_and(|c| c.id == base)); + if !stack_is_base { + return; + } + + stack.segments.drain(1..); + let first_segment = stack.segments.first_mut().expect("non-empty"); + first_segment.commits.clear(); + } +} + +/// Prune the integrated tail whose commits are in `prune_commits`; commits in other +/// territory are kept (e.g. above the target, or reaching it only via a merge's 2nd parent). +/// Cutting happens at whole-GROUP boundaries (the prune map's tokens): a group holding any +/// live commit survives in full. +/// +/// With `keep_if_fully_integrated`, a stack whose every commit would be pruned is left +/// untouched, keeping a fully-integrated branch's tip visible for `integrate_upstream`. +fn prune_integrated_stack_segments( + stack: &mut Stack, + prune_commits: &HashMap, + keep_if_fully_integrated: bool, +) { + // Walk stack segments bottom-up, then prune-groups bottom-up within each stack + // segment. Stop at the first group that is either not fully integrated or not + // in the prunable territory. + let mut cut: Option<(usize, usize)> = None; + // Whether any commit would survive the cut (not integrated, or not prunable). + let mut has_surviving_commit = false; + 'outer: for seg_idx in (0..stack.segments.len()).rev() { + let seg = &stack.segments[seg_idx]; + if seg.commits.is_empty() { + continue; + } + // Contiguous runs of the same prune-group, bottom-up. + let mut end = seg.commits.len(); + while end > 0 { + let group = prune_commits.get(&seg.commits[end - 1].id).copied(); + let mut start = end - 1; + while start > 0 && prune_commits.get(&seg.commits[start - 1].id).copied() == group { + start -= 1; + } + let commits = &seg.commits[start..end]; + if group.is_some() && commits_are_integrated(commits) { + cut = Some((seg_idx, start)); + } else { + has_surviving_commit = true; + break 'outer; + } + end = start; + } + } + + let Some((cut_seg_idx, cut_offset)) = cut else { + return; + }; + + // The whole stack is integrated trunk. While upstream is ahead, keep it (e.g. a + // fully-integrated branch behind its remote) rather than pruning it out of existence. + if keep_if_fully_integrated && !has_surviving_commit { + return; + } + + stack.segments[cut_seg_idx].commits.truncate(cut_offset); + + // Remove all stack segments below the cut. If the cut emptied the topmost + // stack segment, keep it so `remove_empty_branches` can decide whether its + // branch ref should be preserved, e.g. a metadata-tracked branch at the fork point. + let keep = if stack.segments[cut_seg_idx].commits.is_empty() && cut_seg_idx > 0 { + cut_seg_idx + } else { + cut_seg_idx + 1 + }; + stack.segments.truncate(keep); +} + +/// Remote-category refs that NAME territory, per commit: a commit reachable +/// from two remotes has exactly one deliberate owner. +fn remote_naming_positions(cg: &crate::CommitGraph) -> HashMap { + cg.layout() + .map(|l| { + l.segment_naming_placements() + .filter(|(name, _)| name.category() == Some(Category::RemoteBranch)) + .map(|(name, on)| (on, name)) + .collect() + }) + .unwrap_or_default() +} + +/// Every (commit, name) pair where the name is a positioned SEGMENT-naming ref of +/// any category โ€” such refs are structure, never riders. +fn naming_positions(cg: &crate::CommitGraph) -> HashSet<(ObjectId, &gix::refs::FullNameRef)> { + cg.layout() + .map(|l| { + l.segment_naming_placements() + .map(|(name, on)| (on, name.as_ref())) + .collect() + }) + .unwrap_or_default() +} + +fn commits_are_integrated(commits: &[StackCommit]) -> bool { + commits + .iter() + .all(|commit| commit.flags.contains(StackCommitFlags::Integrated)) +} + +/// The prunable territory keyed by COMMIT, each with an opaque group token so pruning +/// still cuts at whole-group boundaries: groups break at positioned naming refs, the +/// arena's segmentation marks. +/// +/// With upstream advanced past the stored target, only the target's FIRST-PARENT trunk +/// is prunable โ€” commits reaching the target via a merge's second parent are off it, so +/// a branch's own work is preserved. Otherwise the territory is the target's top commit +/// plus all its ancestors. +fn prunable_territory( + cg: &crate::CommitGraph, + target_top: ObjectId, + upstream_advanced_past_target: bool, +) -> HashMap { + let naming_at: HashSet = naming_positions(cg).into_iter().map(|(id, _)| id).collect(); + let mut prune_commits = HashMap::::new(); + let mut groups = HashMap::::new(); + let group_of = |anchor: ObjectId, groups: &mut HashMap| { + let next = groups.len(); + *groups.entry(anchor).or_insert(next) + }; + if upstream_advanced_past_target { + let mut anchor = target_top; + let mut cursor = Some(target_top); + while let Some(id) = cursor.take() { + if naming_at.contains(&id) { + anchor = id; + } + if prune_commits + .insert(id, group_of(anchor, &mut groups)) + .is_some() + { + break; + } + cursor = cg.all_parent_ids(id).first().copied(); + } + } else { + let mut queue = vec![(target_top, target_top)]; + while let Some((id, mut anchor)) = queue.pop() { + if naming_at.contains(&id) { + anchor = id; + } + if prune_commits + .insert(id, group_of(anchor, &mut groups)) + .is_some() + { + continue; + } + queue.extend(cg.all_parent_ids(id).into_iter().map(|p| (p, anchor))); + } + } + prune_commits +} + +/// Branch names of applied chains with no commit-bearing segment in ANY stack. Their +/// empty segments are the chain's last projected trace and must survive removal โ€” +/// operations rebuild metadata from the projection, so a vanished chain gets un-applied +/// on disk (e.g. an applied branch merged upstream, spliced as an empty segment into +/// the stack that walks its commit). +fn orphan_chain_names<'m>( + metadata: Option<&'m but_core::ref_metadata::Workspace>, + stacks: &[Stack], +) -> HashSet<&'m gix::refs::FullNameRef> { + metadata + .map(|meta| { + meta.stacks(Applied) + .filter(|ms| { + !stacks.iter().any(|stack| { + stack + .segments + .iter() + .filter(|seg| !seg.commits.is_empty()) + .filter_map(|seg| seg.ref_name()) + .any(|name| { + ms.branches + .iter() + .any(|b| b.ref_name.as_ref() == name && !b.archived) + }) + }) + }) + .flat_map(|ms| { + ms.branches + .iter() + .filter(|b| !b.archived) + .map(|b| b.ref_name.as_ref()) + }) + .collect() + }) + .unwrap_or_default() +} + +/// A recomputed base is shown only when it lies OUTSIDE the workspace's own stacks; a base +/// resting on a sibling stack's territory is hidden. +fn hide_bases_on_sibling_territory(stacks: &mut [Stack]) { + let owned: HashSet = stacks + .iter() + .flat_map(|s| s.segments.iter().flat_map(|seg| &seg.commits)) + .map(|c| c.id) + .collect(); + for stack in stacks { + let own: HashSet = stack + .segments + .iter() + .flat_map(|seg| &seg.commits) + .map(|c| c.id) + .collect(); + if let Some(seg) = stack.segments.last_mut() + && seg + .base + .is_some_and(|b| owned.contains(&b) && !own.contains(&b)) + { + seg.base = None; + } + } +} + +/// Remove empty segments unless they are mentioned in workspace metadata +/// (e.g. a branch the user just added at the fork point with no commits yet). +/// `orphan_chain_names` are branches of applied chains with no commit-bearing +/// segment in ANY stack โ€” their empty segments are the chain's last projected +/// trace and always survive. +fn remove_empty_branches( + stack: &mut Stack, + metadata: Option<&but_core::ref_metadata::Workspace>, + keep_empty_names: &BTreeSet<&gix::refs::FullNameRef>, + orphan_chain_names: &HashSet<&gix::refs::FullNameRef>, + keep_tip: bool, +) { + let own_metadata_stack = stack.id.and_then(|stack_id| { + metadata.and_then(|meta| meta.stacks(Applied).find(|ms| ms.id == stack_id)) + }); + let mut idx = 0; + stack.segments.retain(|seg| { + let is_tip = idx == 0; + idx += 1; + (keep_tip && is_tip) + || !seg.commits.is_empty() + || seg + .ref_name() + .is_some_and(|rn| keep_empty_names.contains(rn) || orphan_chain_names.contains(rn)) + || own_metadata_stack.is_some_and(|ms| { + seg.ref_info + .as_ref() + // NOTE: `!b.archived` compensates for `prune_archived_segments` + // running *before* integrated-commit pruning โ€” archived segments + // that still had commits are skipped there, then emptied here. + // Once metadata is kept trimmed and up-to-date we can drop this. + .is_some_and(|ri| { + ms.branches + .iter() + .any(|b| b.ref_name == ri.ref_name && !b.archived) + }) + }) + }); +} From 2a21fea40d94fe673eabbba7a6f9ac572fff7b77 Mon Sep 17 00:00:00 2001 From: Mattias Granlund Date: Sun, 12 Jul 2026 11:30:06 +0300 Subject: [PATCH 06/11] Add the editor graph: rebase state over the same arena MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit but-rebase's side of the substrate: the editor is created FROM the CommitGraph and mutates it in place. - GraphEditor adopts the arena as its mutable store โ€” mutability follows reachability - the layout API authors ref positions; a name-keyed position table replaces the pointer-store bookkeeping - rewrites happen in place over stable node ids, so the selector- remapping machinery has nothing left to remap Unwired until the switch-over. --- .../src/graph_rebase/graph_editor.rs | 996 ++++++++++++++++++ .../but-rebase/src/graph_rebase/positions.rs | 341 ++++++ crates/but-rebase/src/graph_rebase/ref_ops.rs | 574 ++++++++++ 3 files changed, 1911 insertions(+) create mode 100644 crates/but-rebase/src/graph_rebase/graph_editor.rs create mode 100644 crates/but-rebase/src/graph_rebase/positions.rs create mode 100644 crates/but-rebase/src/graph_rebase/ref_ops.rs diff --git a/crates/but-rebase/src/graph_rebase/graph_editor.rs b/crates/but-rebase/src/graph_rebase/graph_editor.rs new file mode 100644 index 00000000000..247cfb134db --- /dev/null +++ b/crates/but-rebase/src/graph_rebase/graph_editor.rs @@ -0,0 +1,996 @@ +//! An owned arena graph for rebase steps. Each arena node holds a commit id (`None` = +//! tombstone) with its pick options in a parallel settings table, and an ordered parent +//! array โ€” a parent's position in the array is its parent order, with no gaps. References +//! live in the ref table and hold positions. Children are derived from the parent arrays +//! (a maintained reverse index, never independent truth). Nothing is ever removed (a +//! removed pick becomes a `None` payload, a removed reference goes dead in place), so ids +//! stay stable. [`Step`] and [`Pick`] exist only at the API edge: [`GraphEditor::step_view`] +//! builds them, [`GraphEditor::add_node`]/[`GraphEditor::set_step`] take them apart. + +use std::collections::{HashMap, HashSet}; + +use but_core::commit::SignCommit; + +use crate::graph_rebase::{ + Pick, Step, + cherry_pick::{PickMode, TreeMergeMode}, +}; + +/// The stable identifier of an editor-graph entry. Two namespaces, one id type: `Pick` points +/// into the pick arena (its parent array is its truth), `Ref` into the reference table (a +/// position is its truth) โ€” so a selector can address either without knowing which. +/// +/// This is the ADDRESSING type. Namespace-specific operations take [`PickIndex`] or +/// [`RefIndex`] instead, so "a reference in a parent array" is unrepresentable rather than +/// a runtime check. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub(crate) enum EditorIndex { + /// A pick or its tombstone in the pick arena. + Pick(usize), + /// A reference (live or dead) in the ref table. + Ref(usize), +} + +impl EditorIndex { + /// The pick-arena handle, when this addresses a pick or tombstone. + pub(crate) fn as_pick(self) -> Option { + match self { + EditorIndex::Pick(i) => Some(PickIndex(i)), + EditorIndex::Ref(_) => None, + } + } + + /// The ref-table handle, when this addresses a reference. + pub(crate) fn as_ref(self) -> Option { + match self { + EditorIndex::Ref(i) => Some(RefIndex(i)), + EditorIndex::Pick(_) => None, + } + } +} + +impl std::fmt::Display for EditorIndex { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EditorIndex::Pick(i) => write!(f, "p{i}"), + EditorIndex::Ref(i) => write!(f, "r{i}"), + } + } +} + +/// A node in the pick arena โ€” a pick or its tombstone. Nodes are the ONLY entities that +/// carry edges: parent arrays connect nodes, never references. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub(crate) struct PickIndex(pub(crate) usize); + +impl From for EditorIndex { + fn from(n: PickIndex) -> Self { + EditorIndex::Pick(n.0) + } +} + +impl std::fmt::Display for PickIndex { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "p{}", self.0) + } +} + +/// An entry in the reference table, live or dead. References are edgeless โ€” a position is +/// their truth. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub(crate) struct RefIndex(pub(crate) usize); + +impl From for EditorIndex { + fn from(r: RefIndex) -> Self { + EditorIndex::Ref(r.0) + } +} + +impl std::fmt::Display for RefIndex { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "r{}", self.0) + } +} + +/// One incoming child edge of a pick, named POSITIONALLY as `(source node, parent number)`. +/// GroupCarry statements name edges this way, so an edge removed and re-created at the same coordinates +/// is the SAME statement โ€” see [`GroupCarry::Edges`]. +pub(crate) type Edge = (PickIndex, usize); + +/// One reference's editor state: name, mutability, liveness, convergence flag. Deletion +/// flips `live` but keeps the name and position โ€” that matters: selectors taken before the +/// deletion still resolve through the dead record, and rebuilds copy it forward. Where a +/// reference SITS is not +/// state but list structure in the layout table, read via [`GraphEditor::positioned_on`], +/// [`GraphEditor::below_of`] and the derived queries in `positions` (`ref_depth` for rank, +/// `edges_through` for entering edges, `resolve_to_pick` for the pick through tombstones). +#[derive(Debug, Clone)] +pub(crate) struct RefState { + /// The full reference name. + pub refname: gix::refs::FullName, + /// Whether the rebase may move this reference. + pub mutable: bool, + /// `false` once the reference is deleted; the record stays. + pub live: bool, + /// More than one thing (edges and/or stacked refs) converged here โ€” a merge. A + /// creation-time signal distinct from the entering-edge count (a position can converge + /// yet resolve to a single edge), so it is preserved here, never re-derived. + pub ambiguous: bool, +} + +/// Everything a [`Pick`] carries except the commit id โ€” the id is the arena payload itself, +/// so the options live beside it. Stale for tombstones (never read; a revival overwrites). +#[derive(Debug, Clone)] +pub(crate) struct PickSettings { + pub preserved_parents: Option>, + pub pick_mode: PickMode, + pub sign_commit: SignCommit, + pub exclude_from_tracking: bool, + pub conflictable: bool, + pub tree_merge_mode: TreeMergeMode, + pub mutable: bool, +} + +impl PickSettings { + fn split(pick: Pick) -> (gix::ObjectId, Self) { + let Pick { + id, + preserved_parents, + pick_mode, + sign_commit, + exclude_from_tracking, + conflictable, + tree_merge_mode, + mutable, + } = pick; + ( + id, + Self { + preserved_parents, + pick_mode, + sign_commit, + exclude_from_tracking, + conflictable, + tree_merge_mode, + mutable, + }, + ) + } + + fn pick(&self, id: gix::ObjectId) -> Pick { + Pick { + id, + preserved_parents: self.preserved_parents.clone(), + pick_mode: self.pick_mode, + sign_commit: self.sign_commit, + exclude_from_tracking: self.exclude_from_tracking, + conflictable: self.conflictable, + tree_merge_mode: self.tree_merge_mode, + mutable: self.mutable, + } + } +} + +impl Default for PickSettings { + fn default() -> Self { + let (_, settings) = Self::split(Pick::new_pick(gix::ObjectId::null(gix::hash::Kind::Sha1))); + settings + } +} + +/// The editor's carry: [`but_graph::ref_layout::GroupCarry`] over pick handles โ€” the same +/// shape the display side stores, with commit ids mapped to pick handles at creation. +/// Listed edges are always read against the pick's live edges, so an entry whose edge no +/// longer exists is harmless โ€” and comes back on its own if a later mutation re-creates +/// the edge at the same `(child, parent number)`. +pub(crate) type GroupCarry = but_graph::ref_layout::GroupCarry; + +/// The editor's reference group: [`but_graph::ref_layout::RefGroup`] over pick handles โ€” +/// an ordered bottomโ†’top run of member NAMES sharing one [`GroupCarry`]. Order and stacking +/// are implied by the list: the member below is the previous entry, and a member's rank is +/// its index plus the height of whatever the group is attached to (`positions::ref_depth`). +pub(crate) type RefGroup = but_graph::ref_layout::RefGroup; + +/// The editor's graph: a [`but_graph::CommitGraph`] arena where PICKS carry ordered +/// parent arrays, plus a table of [`RefState`]s where REFERENCES carry explicit positions โ€” +/// the arena is the truth for commits, positions the truth for refs, with no overlap. +/// References are edgeless: creation authors their positions straight from the stored +/// [`RefLayout`](but_graph::ref_layout::RefLayout). +#[derive(Debug, Clone, Default)] +pub(crate) struct GraphEditor { + /// THE arena: `EditorIndex::Pick(i)` IS the commit-graph node index `i`. Commit ids are the payload + /// (tombstoning flags a node in place, the node id survives every rewrite); the parent + /// arrays are the ordered structure. + arena: but_graph::CommitGraph, + /// Each node's pick options, parallel to the arena. + settings: Vec, + refs: Vec, + /// Each record's index by CURRENT name โ€” names are unique across live and dead records + /// (re-creating a deleted name resurrects its record), which is why the layout table + /// can identify members by name alone. + by_name: HashMap, + /// THE position store โ€” the editor-space counterpart of the stored + /// [`RefLayout`](but_graph::ref_layout::RefLayout) it is ingested from: per STORED + /// (unresolved) key, the reference groups standing on it. A reference's position (its + /// `on`, its below, its rank, its entering edges) is all list structure here, read via + /// [`Self::position_of`] and `positions::edges_through`. + layout: HashMap>, + /// The derived children index, parallel to the arena: `children[p]` holds every + /// `(child, parent number)` edge naming node `p`, sorted. Maintained through + /// [`Self::update_parents`] โ€” the single seam every parent mutation flows through โ€” + /// so [`Self::incoming_edges`] is a lookup, not an arena scan. + children: Vec>, +} + +impl GraphEditor { + /// Adopt `arena` as the editor's arena. Full commit payloads (flags, refs, generation) + /// survive, which handing the arena back out after a rebase depends on. Every parent + /// edge must already point at a node in the graph โ€” the editor's standing requirement โ€” + /// and the caller follows up with per-node settings via [`Self::set_step`]. + pub(crate) fn adopt(arena: but_graph::CommitGraph) -> Self { + let settings = vec![PickSettings::default(); arena.node_count()]; + let mut children: Vec> = vec![Vec::new(); arena.node_count()]; + for i in 0..arena.node_count() { + for (parent_number, parent) in arena.parent_indices(i).into_iter().enumerate() { + children[parent].push((PickIndex(i), parent_number)); + } + } + Self { + arena, + settings, + refs: Vec::new(), + by_name: HashMap::new(), + layout: HashMap::new(), + children, + } + } + + /// THE arena, read-only โ€” the write-through seam projects it after a rebase. + pub(crate) fn arena(&self) -> &but_graph::CommitGraph { + &self.arena + } + + /// Add `step` to the node arena and return its stable id. References do not belong here โ€” + /// use [`Self::add_reference`]. + pub(crate) fn add_node(&mut self, pick: Option) -> PickIndex { + let (id, settings) = match pick { + Some(pick) => { + let (id, settings) = PickSettings::split(pick); + (Some(id), settings) + } + None => (None, PickSettings::default()), + }; + let i = self.arena.add_node(id); + self.settings.push(settings); + self.children.push(Vec::new()); + debug_assert_eq!( + self.settings.len(), + self.arena.node_count(), + "settings table fell out of step with the arena" + ); + PickIndex(i) + } + + /// Replace the node payload at `entry` โ€” a pick decomposes into id and settings, + /// `None` tombstones the payload (settings go stale, not cleared). + pub(crate) fn set_step(&mut self, entry: PickIndex, pick: Option) { + match pick { + Some(pick) => { + let (id, settings) = PickSettings::split(pick); + self.arena.set_node_id(entry.0, Some(id)); + self.settings[entry.0] = settings; + } + None => self.arena.set_node_id(entry.0, None), + } + } + + /// The step at `entry` as an owned view, synthesized from the payload: id plus settings + /// make a `Step::Pick`, a `None` id (or a dead reference) a `Step::None`. + pub(crate) fn step_view(&self, entry: EditorIndex) -> Step { + match entry { + EditorIndex::Pick(i) => match self.arena.node_payload(i) { + Some(id) => Step::Pick(self.settings[i].pick(id)), + None => Step::None, + }, + EditorIndex::Ref(i) => { + let record = &self.refs[i]; + if record.live { + Step::Reference { + refname: record.refname.clone(), + mutable: record.mutable, + } + } else { + Step::None + } + } + } + } + + /// The commit id of the pick at `entry` โ€” `None` for tombstones and references. The + /// cheap way to read just the id; use [`Self::step_view`] for the whole step. + pub(crate) fn commit_id(&self, entry: impl Into) -> Option { + let entry = entry.into(); + match entry { + EditorIndex::Pick(i) => self.arena.node_payload(i), + EditorIndex::Ref(_) => None, + } + } + + /// Rewrite the commit id of the pick at `entry` IN PLACE โ€” THE rebase write: the node id, + /// its parent array, its settings, and every position naming it all survive unchanged. + pub(crate) fn set_commit_id(&mut self, entry: PickIndex, id: gix::ObjectId) { + debug_assert!( + self.arena.node_payload(entry.0).is_some(), + "tombstones have no commit id" + ); + self.arena.set_commit_id(entry.0, id); + } + + /// Overwrite the preserved parents of the pick at `entry` (see + /// [`Pick::preserved_parents`]). + pub(crate) fn set_preserved_parents( + &mut self, + entry: PickIndex, + parents: Option>, + ) { + debug_assert!( + self.arena.node_payload(entry.0).is_some(), + "tombstones carry no pick options" + ); + self.settings[entry.0].preserved_parents = parents; + } + + /// `true` iff `entry` is a pick โ€” `false` for tombstones and references. + pub(crate) fn is_pick(&self, entry: impl Into) -> bool { + self.commit_id(entry).is_some() + } + + /// All node-arena ids (picks and tombstones), ascending โ€” the type says + /// references are not here; see [`Self::references`] and [`Self::ref_indices`]. + pub(crate) fn node_ids(&self) -> impl Iterator + '_ { + (0..self.arena.node_count()).map(PickIndex) + } + + /// The nodes that no other node lists as a parent โ€” the childless tips, ascending. + /// Callers (head discovery) want picks and tombstones only; references can't appear + /// here by type. + pub(crate) fn tips(&self) -> impl Iterator + '_ { + (0..self.arena.node_count()) + .filter(|&i| self.children[i].is_empty()) + .map(PickIndex) + } + + /// Add a reference and return its stable id. Names are identity: adding a name that + /// already has a record โ€” a re-created deleted ref, say โ€” RESURRECTS that record in + /// place (its retained position stands until the caller re-places it). + pub(crate) fn add_reference( + &mut self, + refname: gix::refs::FullName, + mutable: bool, + ) -> RefIndex { + if let Some(&existing) = self.by_name.get(&refname) { + let record = &mut self.refs[existing.0]; + record.mutable = mutable; + record.live = true; + return existing; + } + let entry = RefIndex(self.refs.len()); + self.by_name.insert(refname.clone(), entry); + self.refs.push(RefState { + refname, + mutable, + live: true, + ambiguous: false, + }); + entry + } + + /// The record registered under `name`, live or dead. + pub(crate) fn entry_of(&self, name: &gix::refs::FullNameRef) -> Option { + self.by_name.get(name).copied() + } + + /// The CURRENT name of the record at `entry`, live or dead. + fn name_of(&self, entry: RefIndex) -> &gix::refs::FullName { + &self.refs[entry.0].refname + } + + /// The reference payload at `entry` โ€” `Some` iff it names a live (non-deleted) reference. + pub(crate) fn reference(&self, entry: EditorIndex) -> Option<(&gix::refs::FullName, bool)> { + let EditorIndex::Ref(i) = entry else { + return None; + }; + let record = self.refs.get(i)?; + record.live.then_some((&record.refname, record.mutable)) + } + + /// `true` iff `entry` is a live reference. + pub(crate) fn is_reference(&self, entry: impl Into) -> bool { + self.reference(entry.into()).is_some() + } + + /// All live references, ascending by id. + pub(crate) fn references( + &self, + ) -> impl Iterator + '_ { + self.refs.iter().enumerate().filter_map(|(i, record)| { + record + .live + .then_some((RefIndex(i), &record.refname, record.mutable)) + }) + } + + /// All reference ids โ€” live AND dead โ€” ascending. Dead references still carry their + /// retained name and position (see [`RefState`]). + pub(crate) fn ref_indices(&self) -> impl Iterator + '_ { + (0..self.refs.len()).map(RefIndex) + } + + /// The full record of the reference at `entry`, including dead ones โ€” rebuilds need the + /// retained payload. + pub(crate) fn state_of(&self, entry: EditorIndex) -> Option<&RefState> { + match entry { + EditorIndex::Ref(i) => self.refs.get(i), + EditorIndex::Pick(_) => None, + } + } + + /// Rename (or resurrect) the reference at `entry` in place; its position is untouched โ€” + /// the layout table's member entry renames with it. + pub(crate) fn set_reference( + &mut self, + entry: RefIndex, + refname: gix::refs::FullName, + mutable: bool, + ) { + let old_name = self.refs[entry.0].refname.clone(); + if old_name != refname { + debug_assert!( + !self.by_name.contains_key(&refname), + "BUG: renaming {old_name} onto a name that already has a record: {refname}" + ); + self.by_name.remove(&old_name); + self.by_name.insert(refname.clone(), entry); + for groups in self.layout.values_mut() { + for group in groups.iter_mut() { + for member in group.members.iter_mut() { + if *member == old_name { + *member = refname.clone(); + } + } + if group.attach.as_ref() == Some(&old_name) { + group.attach = Some(refname.clone()); + } + } + } + } + let record = &mut self.refs[entry.0]; + record.refname = refname; + record.mutable = mutable; + record.live = true; + } + + /// Delete the reference at `entry`: it goes dead in place, retaining name and position + /// (see [`RefState`]). + pub(crate) fn tombstone_reference(&mut self, entry: RefIndex) { + self.refs[entry.0].live = false; + } + + /// The stored key the reference at `entry` stands on (a pick, or its tombstone after + /// deletion), live or dead โ€” `None` until placed. + pub(crate) fn positioned_on(&self, entry: impl Into) -> Option { + let entry = entry.into().as_ref()?; + self.locate(entry).map(|(key, ..)| key) + } + + /// The reference directly underneath `entry` in the physical stack โ€” `None` when it + /// sits directly on its pick (or holds no position at all; see [`Self::is_positioned`]). + pub(crate) fn below_of(&self, entry: impl Into) -> Option { + let entry = entry.into().as_ref()?; + let (key, g, i) = self.locate(entry)?; + let group = &self.layout[&key][g]; + let below = if i > 0 { + Some(&group.members[i - 1]) + } else { + group.attach.as_ref() + }; + below.and_then(|name| self.by_name.get(name.as_ref()).copied()) + } + + /// Whether the reference at `entry` holds a position โ€” only unborn refs don't. + pub(crate) fn is_positioned(&self, entry: impl Into) -> bool { + entry + .into() + .as_ref() + .is_some_and(|entry| self.locate(entry).is_some()) + } + + /// The preserved convergence flag of the reference at `entry` (see [`RefState`]). + pub(crate) fn ambiguous_of(&self, entry: RefIndex) -> bool { + self.refs[entry.0].ambiguous + } + + /// Author a position for `entry`: `entering` is the carry intent โ€” the edges meant to + /// enter through it โ€” classified against `on`'s CURRENT edges (see [`Self::classify`]). + /// Members stacked on the entry ride along; only correct when the entry's edges are + /// already complete. + pub(crate) fn set_position( + &mut self, + entry: RefIndex, + on: PickIndex, + entering: &[Edge], + ambiguous: bool, + below: Option, + ) { + let carry = self.classify(on, entering); + self.extract(entry); + self.place(entry, on, carry, below); + self.set_ambiguous(entry, ambiguous); + } + + /// Splice `entry` out of the physical stack, dependents HEALING past it: members above it + /// in its group close the gap, and groups attached to it re-hang onto what it sat on. + /// The entry itself stays placed as a single-member BRANCH group at the same spot (same + /// key, attached to its old below, carry copied) โ€” the retained position a deletion + /// leaves behind. + pub(crate) fn splice(&mut self, entry: RefIndex) { + let Some((key, g, i)) = self.locate(entry) else { + return; + }; + let name = self.name_of(entry).clone(); + let groups = self.layout.get_mut(&key).expect("just located"); + let below = if i > 0 { + Some(groups[g].members[i - 1].clone()) + } else { + groups[g].attach.clone() + }; + groups[g].members.remove(i); + let carry = groups[g].carry.clone(); + if groups[g].members.is_empty() { + groups.remove(g); + } + for groups in self.layout.values_mut() { + for group in groups.iter_mut() { + if group.attach.as_ref() == Some(&name) { + group.attach = below.clone(); + } + } + } + self.layout.entry(key).or_default().push(RefGroup { + members: vec![name], + carry, + attach: below, + }); + self.coalesce_groups(key); + } + + /// Join `entry` into the group of `mate` โ€” copying the mate's key, carry, and ambiguity โ€” + /// sitting on `below`. + pub(crate) fn join_group_of( + &mut self, + entry: RefIndex, + mate: RefIndex, + below: Option, + ) { + let Some((key, g, _)) = self.locate(mate) else { + return; + }; + let carry = self.layout[&key][g].carry.clone(); + let ambiguous = self.refs[mate.0].ambiguous; + self.extract(entry); + self.place(entry, key, carry, below); + self.set_ambiguous(entry, ambiguous); + } + + /// Re-key `entry`'s position onto `onto`, carrying its CURRENT carry โ€” as maintained + /// through edge surgery. Below and ambiguity are preserved; members stacked on the entry + /// ride along. + pub(crate) fn rekey_position(&mut self, entry: RefIndex, onto: PickIndex) { + let Some(on) = self.positioned_on(entry) else { + return; + }; + if on == onto { + return; + } + let below = self.below_of(entry); + let carry = self.carry_of(entry).cloned().unwrap_or(GroupCarry::All); + self.extract(entry); + self.place(entry, onto, carry, below); + } + + /// Re-hang `entry` onto `below` โ€” an adjacency statement only; its key, carry, and + /// ambiguity are untouched, and members stacked on the entry ride along. + pub(crate) fn set_below(&mut self, entry: RefIndex, below: Option) { + let Some(on) = self.positioned_on(entry) else { + return; + }; + if self.below_of(entry) == below { + return; + } + let carry = self.carry_of(entry).cloned().unwrap_or(GroupCarry::All); + self.extract(entry); + self.place(entry, on, carry, below); + } + + /// Point a dead reference's kept position at `on` โ€” the bare pointer that stale + /// selectors resolve through. Its old spot heals (dependents re-hang past it), + /// and it lands as a bare root; live references re-place via the layout machinery + /// instead. + pub(crate) fn set_retained_position(&mut self, entry: RefIndex, on: PickIndex) { + debug_assert!( + !self.is_reference(entry), + "retained positions belong to dead references" + ); + self.splice(entry); + self.extract(entry); + self.place(entry, on, GroupCarry::None, None); + self.set_ambiguous(entry, false); + } + + /// The carry of the group holding the reference at `entry`, if it holds a position. + pub(crate) fn carry_of(&self, entry: impl Into) -> Option<&GroupCarry> { + let entry = entry.into().as_ref()?; + let (key, g, _) = self.locate(entry)?; + Some(&self.layout[&key][g].carry) + } + + /// All positioned references โ€” live AND dead โ€” ascending by id. + pub(crate) fn positioned_refs(&self) -> impl Iterator + '_ { + (0..self.refs.len()) + .map(RefIndex) + .filter(|&entry| self.locate(entry).is_some()) + } + + /// Where `entry` sits in the layout table: `(key, group index, member index)`. + /// Membership is by NAME โ€” the record's current name is the table identity. + fn locate(&self, entry: RefIndex) -> Option<(PickIndex, usize, usize)> { + let name = self.name_of(entry); + self.layout.iter().find_map(|(&key, groups)| { + groups.iter().enumerate().find_map(|(g, group)| { + group + .members + .iter() + .position(|m| m == name) + .map(|i| (key, g, i)) + }) + }) + } + + /// Classify a position's entering-edge intent against `on`'s CURRENT edges: empty is a + /// root, the whole live set the shared `All` carry, any other set an `Edges` carry + /// stating exactly those edges. + fn classify(&self, on: PickIndex, entering: &[Edge]) -> GroupCarry { + if entering.is_empty() { + return GroupCarry::None; + } + let live = match crate::graph_rebase::positions::resolve_to_pick(self, on) { + Some(pick) => crate::graph_rebase::positions::edges_into(self, pick), + None => Vec::new(), + }; + let edge_set: HashSet<_> = entering.iter().copied().collect(); + let live_set: HashSet<_> = live.iter().copied().collect(); + if edge_set == live_set { + GroupCarry::All + } else { + let mut edges = entering.to_vec(); + edges.sort_unstable(); + edges.dedup(); + GroupCarry::Edges(edges) + } + } + + /// Take `entry` out of the table, its dependents RIDING: members stacked above it in its + /// group split off as their own group attached to `entry` (they keep sitting on it, + /// wherever it goes next), and groups attached to `entry` stay attached. The caller + /// re-places the entry immediately. + fn extract(&mut self, entry: RefIndex) { + let Some((key, g, i)) = self.locate(entry) else { + return; + }; + let name = self.name_of(entry).clone(); + let groups = self.layout.get_mut(&key).expect("just located"); + let above = groups[g].members.split_off(i + 1); + groups[g].members.pop(); + if !above.is_empty() { + let carry = groups[g].carry.clone(); + groups.push(RefGroup { + members: above, + carry, + attach: Some(name), + }); + } + if groups[g].members.is_empty() { + groups.remove(g); + } + if groups.is_empty() { + self.layout.remove(&key); + } + } + + /// Put the (extracted or fresh) `entry` into the table at `key` via the shared + /// [`but_graph::ref_layout::place_in_groups`] โ€” the same algorithm the builder authors with. + fn place( + &mut self, + entry: RefIndex, + key: PickIndex, + carry: GroupCarry, + attach: Option, + ) { + let name = self.name_of(entry).clone(); + let attach = attach.map(|b| self.name_of(b).clone()); + but_graph::ref_layout::place_in_groups( + self.layout.entry(key).or_default(), + name, + carry, + attach, + ); + } + + fn coalesce_groups(&mut self, key: PickIndex) { + if let Some(groups) = self.layout.get_mut(&key) { + but_graph::ref_layout::coalesce_groups(groups); + } + } + + fn set_ambiguous(&mut self, entry: RefIndex, ambiguous: bool) { + self.refs[entry.0].ambiguous = ambiguous; + } + + /// Overwrite whether the rebase may move the reference at `entry`. + pub(crate) fn set_ref_mutable(&mut self, entry: RefIndex, mutable: bool) { + self.refs[entry.0].mutable = mutable; + } + + /// The references carrying the edge `(child, parent number)` into `parent`: each carrying + /// group answers with its TOP member โ€” the below-walk covers the rest. + pub(crate) fn edge_carriers( + &self, + parent: PickIndex, + child: PickIndex, + parent_number: usize, + ) -> impl Iterator + '_ { + self.layout + .get(&parent) + .into_iter() + .flatten() + .filter(move |group| match &group.carry { + GroupCarry::None => false, + GroupCarry::All => true, + GroupCarry::Edges(edges) => edges.contains(&(child, parent_number)), + }) + .filter_map(|group| group.members.last()) + .filter_map(|name| self.by_name.get(name).copied()) + } + + pub(crate) fn set_ref_ambiguous(&mut self, entry: RefIndex, ambiguous: bool) { + self.set_ambiguous(entry, ambiguous); + } + + /// Adopt `groups` wholesale as `key`'s reference groups โ€” creation's id-mapped copy of + /// the stored layout. Every member name must already be registered. + pub(crate) fn insert_groups(&mut self, key: PickIndex, groups: Vec) { + debug_assert!( + groups + .iter() + .flat_map(|g| g.members.iter()) + .all(|name| self.by_name.contains_key(name.as_ref())), + "BUG: ingest must register every reference before copying groups" + ); + self.layout.entry(key).or_default().extend(groups); + } + + /// The ordered parents of `entry` โ€” parent number position is the parent order; references + /// have none. + pub(crate) fn parents(&self, entry: impl Into) -> Vec { + match entry.into() { + EditorIndex::Pick(i) => self + .arena + .parent_indices(i) + .into_iter() + .map(PickIndex) + .collect(), + EditorIndex::Ref(_) => Vec::new(), + } + } + + /// How many parents `entry` has. + pub(crate) fn parent_count(&self, entry: impl Into) -> usize { + self.parents(entry).len() + } + + /// Every edge that names `entry` as a parent, as sorted `(child, parent number)` + /// pairs โ€” answered from the maintained children index, not an arena scan. + pub(crate) fn incoming_edges(&self, entry: impl Into) -> &[Edge] { + let entry = entry.into(); + match entry { + EditorIndex::Pick(i) => &self.children[i], + EditorIndex::Ref(_) => &[], + } + } + + /// Append `parent` as `child`'s last parent; returns its parent number. + pub(crate) fn push_parent(&mut self, child: PickIndex, parent: PickIndex) -> usize { + self.update_parents(child, |parents| { + parents.push(parent); + parents.len() - 1 + }) + } + + /// Insert `parent` at `parent number` of `child` (clamped to the array end); later parent numbers shift up + /// with their statements. Returns the parent number actually used. + pub(crate) fn insert_parent( + &mut self, + child: PickIndex, + parent_number: usize, + parent: PickIndex, + ) -> usize { + let len = self.parent_count(child); + let parent_number = parent_number.min(len); + let renames: Vec<_> = (parent_number..len) + .map(|s| ((child, s), (child, s + 1))) + .collect(); + self.rename_edges(&renames); + self.update_parents(child, |parents| parents.insert(parent_number, parent)); + parent_number + } + + /// Remove `child`'s parent at `parent number`, returning it; later parent numbers shift down with their + /// statements, and statements naming the removed parent number are dropped. + pub(crate) fn remove_parent( + &mut self, + child: PickIndex, + parent_number: usize, + ) -> Option { + let len = self.parent_count(child); + if parent_number >= len { + return None; + } + let target = self.update_parents(child, |parents| parents.remove(parent_number)); + self.retain_edges(|&edge| edge != (child, parent_number)); + let renames: Vec<_> = (parent_number + 1..len) + .map(|s| ((child, s), (child, s - 1))) + .collect(); + self.rename_edges(&renames); + Some(target) + } + + /// Re-point `child`'s parent at `parent number` onto `new_parent`. The parent number โ€” and so the + /// statement name โ€” is untouched: groups stated on the edge follow it to its new target. + pub(crate) fn replace_parent( + &mut self, + child: PickIndex, + parent_number: usize, + new_parent: PickIndex, + ) { + self.update_parents(child, |parents| match parents.get_mut(parent_number) { + Some(entry) => *entry = new_parent, + None => debug_assert!( + false, + "replace_parent: {child} has no parent_number {parent_number}" + ), + }); + } + + /// Move `from`'s whole parent array onto `to` (which must have none); statements follow + /// parent number-for-parent number. + pub(crate) fn transplant_parents(&mut self, from: PickIndex, to: PickIndex) { + debug_assert_eq!( + self.parent_count(to), + 0, + "transplant target {to} already has parents" + ); + let parents = self.update_parents(from, std::mem::take); + let renames: Vec<_> = (0..parents.len()).map(|s| ((from, s), (to, s))).collect(); + self.update_parents(to, |parent_number| *parent_number = parents); + self.rename_edges(&renames); + } + + /// Re-target every parent-array entry naming `from` onto `to`, parent numbers preserved โ€” + /// statement names are `(source, parent number)`, so they stay valid untouched. + pub(crate) fn redirect_children(&mut self, from: PickIndex, to: PickIndex) { + // The index is sorted by child, so consecutive edges of one child dedup away. + let mut children: Vec = self + .incoming_edges(from) + .iter() + .map(|&(child, _)| child) + .collect(); + children.dedup(); + for child in children { + self.update_parents(child, |parents| { + for parent in parents.iter_mut() { + if *parent == from { + *parent = to; + } + } + }); + } + } + + /// Empty `child`'s parent array, returning it. Group statements naming the drained parent numbers + /// are DELIBERATELY untouched: the caller re-states the orphaned names onto their new + /// carrier itself (the below-insert path renames them onto the range's parent-most). + pub(crate) fn drain_parents(&mut self, child: PickIndex) -> Vec { + self.update_parents(child, std::mem::take) + } + + /// Apply several edge renames SIMULTANEOUSLY: every stated carry edge is matched against + /// the pre-rename names once, so shifting parent numbers in a renumber can't collide mid-flight. + /// Each renamed edge is `(old, new)` โ€” a parent number renumbered or re-sourced onto another pick. + pub(crate) fn rename_edges(&mut self, renames: &[(Edge, Edge)]) { + for groups in self.layout.values_mut() { + for group in groups.iter_mut() { + let GroupCarry::Edges(edges) = &mut group.carry else { + continue; + }; + let mut changed = false; + for edge in edges.iter_mut() { + if let Some((_, new)) = renames.iter().find(|(old, _)| old == edge) { + *edge = *new; + changed = true; + } + } + if changed { + edges.sort_unstable(); + edges.dedup(); + } + } + } + } + + // --- The parent arrays --- + // + // Carry entries name edges as `(child, parent number)`, and mutators keep those names + // current: shifting a parent number renames the entries with it. Removing a parent drops + // its entries for good โ€” an operation that wants them back must state them again; the + // store never resurrects them by accident. + + /// Drop every stated carry edge `keep` rejects. + fn retain_edges(&mut self, keep: impl Fn(&Edge) -> bool) { + for groups in self.layout.values_mut() { + for group in groups.iter_mut() { + if let GroupCarry::Edges(edges) = &mut group.carry { + edges.retain(&keep); + } + } + } + } + + /// Rewrite `child`'s parent array through `f` โ€” the single seam every parent mutation + /// flows through into the arena's parent number write. Parents are [`PickIndex`] by type: + /// references in an edge are unrepresentable. + fn update_parents( + &mut self, + child: PickIndex, + f: impl FnOnce(&mut Vec) -> R, + ) -> R { + let old = self.arena.parent_indices(child.0); + let mut parents: Vec = old.iter().copied().map(PickIndex).collect(); + let result = f(&mut parents); + let targets: Vec = parents.into_iter().map(|parent| parent.0).collect(); + for (parent_number, &parent) in old.iter().enumerate() { + let edges = &mut self.children[parent]; + if let Ok(at) = edges.binary_search(&(child, parent_number)) { + edges.remove(at); + } + } + for (parent_number, &parent) in targets.iter().enumerate() { + let edges = &mut self.children[parent]; + match edges.binary_search(&(child, parent_number)) { + Err(at) => edges.insert(at, (child, parent_number)), + Ok(_) => debug_assert!( + false, + "children index already names ({child}, {parent_number})" + ), + } + } + // Preserved parents pin the pick's onto-commits only while its edges are + // untouched (they carry raw parents the walk didn't materialize). Once a + // mutation rewrites the parent array, the live edges are the truth โ€” a stale + // preserved list would make the rebase silently ignore the reparenting. + if targets.as_slice() != old && self.settings[child.0].preserved_parents.is_some() { + self.settings[child.0].preserved_parents = None; + } + self.arena.set_parents(child.0, targets); + result + } +} diff --git a/crates/but-rebase/src/graph_rebase/positions.rs b/crates/but-rebase/src/graph_rebase/positions.rs new file mode 100644 index 00000000000..1ab04e9dd36 --- /dev/null +++ b/crates/but-rebase/src/graph_rebase/positions.rs @@ -0,0 +1,341 @@ +//! Where each reference sits, stored as position data rather than as graph edges. +//! +//! A commit ([`Step::Pick`]) carries parent edges; a reference ([`Step::Reference`]) carries +//! NONE. Instead every reference stands in the arrangement table (`GraphEditor::layout`): +//! per stored key, an ordered list of groups (`RefGroup`), each a bottomโ†’top run of references +//! sharing one [`GroupCarry`]. A position reads back as a [`RefPosition`] view: `on` (the entry +//! pointed at, followed through tombstones), `below` (the reference directly underneath; +//! `None` = on the pick), and `ambiguous` (this position is a merge). +//! +//! Keeping references out of the edge graph is deliberate: an edge running THROUGH a reference +//! would make it bear connectivity it shouldn't โ€” gluing a commit's history onto whatever else +//! the reference happens to touch. +//! +//! Vocabulary used throughout this module: +//! - **edge** โ€” a parent edge of the commit graph, named from its child side as +//! `(child entry, parent number)`, since edges live in parent arrays. A pick's INCOMING edges +//! are its children's edges pointing at it. +//! - **enters through** โ€” an incoming edge of a pick ENTERS THROUGH a reference when it +//! descends into that reference's position (see [`edges_through`]). This distinguishes +//! co-located references and picks out which merge group a reference belongs to. +//! - **group** โ€” references stacked on one pick, ordered by their below walk ([`ref_depth`]). +//! Groups are shallow in practice (โ‰ค3 observed). + +use crate::graph_rebase::graph_editor::GroupCarry; +use crate::graph_rebase::graph_editor::{PickIndex, RefIndex}; +use crate::graph_rebase::{EditorIndex, GraphEditor}; + +/// Resolve `entry` to the pick it stands for: a pick is itself, a tombstone follows its +/// preserved first edge downward, a reference goes via its stored position โ€” dead references +/// via their RETAINED position, which stale selectors normalize through (unborn refs carry +/// none and resolve to nothing). +pub(crate) fn resolve_to_pick( + graph: &GraphEditor, + entry: impl Into, +) -> Option { + // A reference resolves via its (retained) stored position; unborn refs carry none and + // resolve to nothing. + let mut node = match entry.into() { + EditorIndex::Pick(i) => PickIndex(i), + entry @ EditorIndex::Ref(_) => graph.positioned_on(entry.as_ref()?)?, + }; + // Tombstones descend their preserved first edge. + for _ in 0..10_000 { + if graph.is_pick(node) { + return Some(node); + } + node = *graph.parents(node).first()?; + } + None +} + +/// A pick's incoming edges as sorted `(child, parent number)` pairs. The groups on the pick +/// divide these among themselves (their [`GroupCarry`]); [`edges_through`] reads one group's share. +pub(crate) fn edges_into(graph: &GraphEditor, pick: PickIndex) -> Vec<(PickIndex, usize)> { + graph + .incoming_edges(pick) + .iter() + .copied() + .filter(|&(child, _)| graph.is_pick(child)) + .collect() +} + +/// The edges currently entering through the reference at `entry`: the group's own carry +/// statement (kept aligned by the parent number mutators), ordered and filtered by the resolved pick's +/// live edges so a stale carry edge never reaches a consumer. +pub(crate) fn edges_through( + graph: &GraphEditor, + entry: impl Into, +) -> Vec<(PickIndex, usize)> { + let Some(entry) = entry.into().as_ref() else { + return Vec::new(); + }; + let Some(on) = graph.positioned_on(entry) else { + return Vec::new(); + }; + let Some(carry) = graph.carry_of(entry) else { + return Vec::new(); + }; + let edges = match resolve_to_pick(graph, on) { + Some(pick) => edges_into(graph, pick), + None => Vec::new(), + }; + match carry { + GroupCarry::None => Vec::new(), + GroupCarry::All => edges, + GroupCarry::Edges(stated) => edges + .into_iter() + .filter(|edge| stated.contains(edge)) + .collect(), + } +} + +/// The members of `ref_node`'s group โ€” every reference with the same resolved pick and the +/// same (derived) entering edges. +pub(crate) fn group_members( + graph: &GraphEditor, + ref_node: impl Into, +) -> Vec { + let Some(ref_node) = ref_node.into().as_ref() else { + return vec![]; + }; + if !graph.is_positioned(ref_node) { + return vec![]; + } + let pick = resolve_to_pick(graph, ref_node); + let entering = edges_through(graph, ref_node); + graph + .positioned_refs() + .filter(|&entry| { + edges_through(graph, entry) == entering && resolve_to_pick(graph, entry) == pick + }) + .collect() +} + +/// Every reference whose stored `on`, followed through tombstones, resolves to `pick`. +/// Order is unspecified (ascending entry id). +pub(crate) fn refs_resolving_to(graph: &GraphEditor, pick: PickIndex) -> Vec { + graph + .positioned_refs() + .filter(|&entry| resolve_to_pick(graph, entry) == Some(pick)) + .collect() +} + +/// The references reachable from `start`, given the PICK set it reached. When `start` is +/// itself a reference, it counts too. +pub(crate) fn refs_reachable_with( + graph: &GraphEditor, + start: EditorIndex, + picks: &std::collections::HashSet, +) -> Vec { + // Match by commit id as well as entry: a graph can hold the same commit twice (in a + // stack and in the target's history), and a reference counts as reached when its commit + // was reached under either entry. Deleting a branch that merges back in relies on this. + let reached_ids: std::collections::HashSet = picks + .iter() + .filter_map(|entry| graph.commit_id(*entry)) + .collect(); + let mut out = Vec::new(); + for entry in graph.positioned_refs() { + // Pick-based reachability, commit-equivalent across duplicate groups. + let pick_reached = resolve_to_pick(graph, entry).is_some_and(|pick| { + picks.contains(&pick) + || graph + .commit_id(pick) + .is_some_and(|id| reached_ids.contains(&id)) + }); + if pick_reached || EditorIndex::from(entry) == start { + out.push(entry); + } + } + out +} + +/// The reference's depth above its pick โ€” the length of its below walk (0 = directly on +/// the pick). This IS the rank: order among co-located references is adjacency, not a number. +pub(crate) fn ref_depth(graph: &GraphEditor, entry: impl Into) -> usize { + let Some(entry) = entry.into().as_ref() else { + return 0; + }; + let mut depth = 0usize; + let mut cursor = graph.below_of(entry); + while let Some(b) = cursor { + depth += 1; + if depth > 10_000 { + debug_assert!(false, "below walk cycle at ref {entry}"); + return depth; + } + cursor = graph.below_of(b); + } + depth +} + +/// A group about to be entered by a new edge, captured BEFORE that edge exists so +/// [`apply_group_join`] never reads a half-updated store. +pub(crate) struct GroupJoin { + /// The joining members: the reference and the group-mates its below walk rests on โ€” + /// each with its position captured (`on`, `below`, `ambiguous`). Root groups (no + /// entering edges) at one pick are distinct siblings, so only the reference itself + /// joins. + members: Vec<(RefIndex, PickIndex, Option, bool)>, + /// The edges entering the group at capture time. + entering: Vec<(PickIndex, usize)>, +} + +/// Capture `ref_node`'s group for a coming join โ€” call BEFORE the joining edge is added. +pub(crate) fn prepare_group_join(graph: &GraphEditor, ref_node: RefIndex) -> GroupJoin { + let capture = |entry: RefIndex| { + graph + .positioned_on(entry) + .map(|on| (entry, on, graph.below_of(entry), graph.ambiguous_of(entry))) + }; + let Some(captured) = capture(ref_node) else { + return GroupJoin { + members: Vec::new(), + entering: Vec::new(), + }; + }; + let is_root = matches!(graph.carry_of(ref_node), Some(GroupCarry::None)); + let members = if is_root { + vec![captured] + } else { + // Walk the below walk keeping members of this group โ€” the physical stack may pass + // through other groups' refs. + let group = group_members(graph, ref_node); + let mut members = vec![captured]; + let mut cursor = graph.below_of(ref_node); + while let Some(b) = cursor { + if !graph.is_positioned(b) { + break; + } + if group.contains(&b) { + members.extend(capture(b)); + } + cursor = graph.below_of(b); + } + members + }; + GroupJoin { + members, + entering: edges_through(graph, ref_node), + } +} + +/// The new `edge` enters the captured group: every member gains it among its entering edges, +/// classified against the pick's now-complete edges โ€” call right AFTER the edge is added. An +/// `All` group stays `All`; an `Edges` group gains the edge; a Root descends. +pub(crate) fn apply_group_join( + graph: &mut GraphEditor, + join: &GroupJoin, + edge: (PickIndex, usize), +) { + for &(entry, on, below, was_ambiguous) in &join.members { + let mut entering = join.entering.clone(); + if !entering.contains(&edge) { + entering.push(edge); + } + let ambiguous = was_ambiguous || entering.len() > 1; + graph.set_position(entry, on, &entering, ambiguous, below); + } +} + +/// Move every reference resolving to `from_pick` onto `to_pick`. +/// +/// With `reclassify` false the carry kind is PRESERVED (an `All` group derives its edges at +/// `to_pick`, robust to the reconnect renumbering parent numbers); with it true the current derived +/// edges are re-classified against `to_pick`'s, so a ref sliding onto a dup-parent MERGE base +/// splits into the `Edges` group its edge occupies. `ambiguous` is preserved. +/// Preserve-vs-reclassify is per-situation, not cleanly per-caller โ€” each site picks based on +/// whether the edge set should survive the move or be re-derived at the destination. +pub(crate) fn reposition_refs( + graph: &mut GraphEditor, + from_pick: PickIndex, + to_pick: PickIndex, + reclassify: bool, +) { + let moves = refs_resolving_to(graph, from_pick); + for entry in moves { + if reclassify { + let entering = edges_through(graph, entry); + let (ambiguous, below) = (graph.ambiguous_of(entry), graph.below_of(entry)); + graph.set_position(entry, to_pick, &entering, ambiguous, below); + } else { + graph.rekey_position(entry, to_pick); + } + } +} + +/// Every reference has a well-formed position, and positions are unique wherever order +/// matters topologically โ€” within groups entered by a child edge (non-empty +/// [`edges_through`]). Several root groups above one pick are fine: they have no meaningful +/// order, and display sorts them by name. +/// +/// Wired at editor creation AND at rebase entry, so every graph shape the suite produces โ€” +/// including post-mutation shapes โ€” continuously validates the position model. +pub(crate) fn debug_assert_positions_total(graph: &GraphEditor) { + if !cfg!(debug_assertions) { + return; + } + debug_assert_below_wellformed(graph); + type OrderedPositionKey = (Option, Vec<(PickIndex, usize)>, usize); + let mut seen: std::collections::HashMap = Default::default(); + for entry in graph.references().map(|(entry, _, _)| entry) { + // No stored position is only legitimate for unborn refs (no pick below at creation). + if !graph.is_positioned(entry) { + continue; + } + let entering = edges_through(graph, entry); + if entering.is_empty() { + continue; + } + let pick = resolve_to_pick(graph, entry); + let rank = ref_depth(graph, entry); + if let Some(previous) = seen.insert((pick, entering.clone(), rank), entry) { + debug_assert!( + false, + "references {previous} and {entry} collide at position \ + (pick {pick:?}, entering {entering:?}, rank {rank})" + ); + } + } +} + +/// Every stored `below` of a LIVE reference names a positioned reference resolving to the SAME +/// pick, and the below walk is acyclic. Tombstoned refs keep their stored position for +/// retention reads but are spliced out of the physical stack, so only live refs are graded. +fn debug_assert_below_wellformed(graph: &GraphEditor) { + let name = |entry: RefIndex| match graph.reference(entry.into()) { + Some((refname, _)) => refname.to_string(), + None => format!("{:?}", graph.step_view(entry.into())), + }; + for entry in graph.positioned_refs() { + if !graph.is_reference(entry) { + continue; + } + let pick = resolve_to_pick(graph, entry); + let mut depth = 0usize; + let mut cursor = graph.below_of(entry); + while let Some(b) = cursor { + if !graph.is_positioned(b) { + debug_assert!( + false, + "ref {entry}: below {b} is not a positioned reference" + ); + return; + } + debug_assert_eq!( + resolve_to_pick(graph, b), + pick, + "ref {entry} ({}): below {b} ({}) resolves to a different pick", + name(entry), + name(b) + ); + depth += 1; + debug_assert!(depth <= 10_000, "ref {entry}: below walk cycle"); + if depth > 10_000 { + return; + } + cursor = graph.below_of(b); + } + } +} diff --git a/crates/but-rebase/src/graph_rebase/ref_ops.rs b/crates/but-rebase/src/graph_rebase/ref_ops.rs new file mode 100644 index 00000000000..1044b0d9dff --- /dev/null +++ b/crates/but-rebase/src/graph_rebase/ref_ops.rs @@ -0,0 +1,574 @@ +//! The reference-op API: mutation sites say where a reference should end up +//! ([`StackPlace`]) and let [`place_ref`] and friends work out the `(on, below, group)` +//! details. +//! +//! These ops read and write the layout table: per pick, an ordered list of groups of ref +//! NAMES โ€” the shape of workspace metadata โ€” with pick, rank, and entering edges DERIVED +//! from the table plus live pick edges (`positioned_on`, `ref_depth`, `edges_through`). +//! Names never churn under graph mutation, which is what keeps the table stable while +//! commits are rewritten around it. + +use crate::graph_rebase::GraphEditor; +use crate::graph_rebase::graph_editor::{Edge, PickIndex, RefIndex}; +use crate::graph_rebase::positions; + +/// A position in a commit's reference stack, named by intent. +#[derive(Debug, Clone, Copy)] +pub(crate) enum StackPlace { + /// Directly above this reference in its group: mates that sat on it re-hang onto the + /// newcomer. + Above(RefIndex), + /// At this reference's position: the newcomer takes its below, and it re-hangs onto the + /// newcomer. + Below(RefIndex), + /// The bottom of the pick's whole stack (carrying all its edges โ€” "the branch here"); + /// every reference that sat on the pick itself re-hangs onto the newcomer. + Bottom(PickIndex), + /// The top of the group the edge `(child, parent number)` carries into `pick`. + GroupTop { + /// The commit the group sits on. + pick: PickIndex, + /// The child edge whose group the reference stacks onto. + edge: (PickIndex, usize), + }, + /// A fresh root above `pick`: nothing descends into it, no other position moves. + Root(PickIndex), +} + +/// Re-hang the references sitting directly ON `pick` (no `below` of their own) onto +/// `moving`, which is taking the pick-bottom spot; `moving` itself is left alone. +fn rehang_pick_bottom(graph: &mut GraphEditor, pick: PickIndex, moving: RefIndex) { + let rehang: Vec<_> = graph + .positioned_refs() + .filter(|&mate| { + mate != moving + && graph.below_of(mate).is_none() + && positions::resolve_to_pick(graph, mate) == Some(pick) + }) + .collect(); + for mate in rehang { + graph.set_below(mate, Some(moving)); + } +} + +/// The topmost reference on `pick` entered through exactly `entering` โ€” the one a +/// group-top placement stacks above; `moving` itself is not a candidate. +fn group_top_at( + graph: &GraphEditor, + pick: PickIndex, + entering: &[Edge], + moving: RefIndex, +) -> Option { + graph + .positioned_refs() + .filter(|&mate| { + mate != moving + && positions::resolve_to_pick(graph, mate) == Some(pick) + && positions::edges_through(graph, mate) == entering + }) + .max_by_key(|&mate| (positions::ref_depth(graph, mate), mate)) +} + +/// Place the reference at `entry` into `place`, shifting other positions as the placement demands. +/// Fresh placement only โ€” moving an existing reference is [`move_ref`]. +pub(crate) fn place_ref(graph: &mut GraphEditor, entry: RefIndex, place: StackPlace) { + match place { + StackPlace::Above(target) => { + if !graph.is_positioned(target) { + return; + } + // Same-group members that sat directly on the target now sit on the + // interposed entry; cross-group members on the target keep it (they branch). + let rehang: Vec<_> = positions::group_members(graph, target) + .into_iter() + .filter(|&mate| mate != entry && graph.below_of(mate) == Some(target)) + .collect(); + for mate in rehang { + graph.set_below(mate, Some(entry)); + } + graph.join_group_of(entry, target, Some(target)); + } + StackPlace::Below(target) => { + if !graph.is_positioned(target) { + return; + } + let below = graph.below_of(target); + graph.join_group_of(entry, target, below); + graph.set_below(target, Some(entry)); + } + StackPlace::Bottom(pick) => { + let entering = positions::edges_into(graph, pick); + // Members that sat on the pick itself now sit on the new bottom. + rehang_pick_bottom(graph, pick, entry); + graph.set_position(entry, pick, &entering, entering.len() > 1, None); + } + StackPlace::GroupTop { pick, edge } => { + let entering = vec![edge]; + let top = group_top_at(graph, pick, &entering, entry); + graph.set_position(entry, pick, &entering, false, top); + } + StackPlace::Root(pick) => { + graph.set_position(entry, pick, &[], false, None); + } + } +} + +/// Move the reference at `entry` into `place`. Unlike [`place_ref`], the reference already +/// holds a position: when it is the sole carrier of its entering edges, those follow it +/// (re-pointed onto the new pick and merged into the destination's entering set), and โ€” when +/// moving above another reference โ€” the members now entered through it share the merged set. +pub(crate) fn move_ref(graph: &mut GraphEditor, entry: RefIndex, place: StackPlace) { + let Some(moving_on) = graph.positioned_on(entry) else { + return; + }; + let moving_edges = positions::edges_through(graph, entry); + // Sole carrier = nothing in the group sits below the mover. Measured before any shuffling + // (the shuffles never change which members those are). + let moving_depth = positions::ref_depth(graph, entry); + let sole_carrier = !positions::group_members(graph, entry) + .into_iter() + .any(|mate| mate != entry && positions::ref_depth(graph, mate) < moving_depth); + // The mover vacates its old spot: members stacked directly on it settle onto what it sat + // on. + graph.splice(entry); + // Each arm yields (on, entering, below); the mover's edges merge into `entering` below, + // after re-pointing, so the final `set_position` sees the complete set. Each arm hangs the + // mover on its new below FIRST so re-hanging mates never creates a transient below-cycle + // through its stale pointer. + let (on, mut entering, below) = match place { + StackPlace::Above(target) => { + let Some(t_on) = graph.positioned_on(target) else { + return; + }; + graph.set_below(entry, Some(target)); + // Same-group members that sat directly on the target now sit on the mover. + let rehang: Vec<_> = positions::group_members(graph, target) + .into_iter() + .filter(|&mate| mate != entry && graph.below_of(mate) == Some(target)) + .collect(); + for mate in rehang { + graph.set_below(mate, Some(entry)); + } + (t_on, positions::edges_through(graph, target), Some(target)) + } + StackPlace::Below(target) => { + let Some(t_on) = graph.positioned_on(target) else { + return; + }; + let t_below = graph.below_of(target); + graph.set_below(entry, t_below); + graph.set_below(target, Some(entry)); + (t_on, positions::edges_through(graph, target), t_below) + } + StackPlace::Bottom(pick) => { + // Refs that sat on the pick itself re-hang onto the mover. + graph.set_below(entry, None); + rehang_pick_bottom(graph, pick, entry); + (pick, Vec::new(), None) + } + StackPlace::GroupTop { pick, edge } => { + let entering = vec![edge]; + let top = group_top_at(graph, pick, &entering, entry); + graph.set_below(entry, top); + (pick, entering, top) + } + StackPlace::Root(pick) => { + graph.set_below(entry, None); + (pick, Vec::new(), None) + } + }; + // The edges follow the mover only when it was their sole carrier: group members staying + // behind keep their entering edges. + let old_resolved = positions::resolve_to_pick(graph, moving_on); + let new_resolved = positions::resolve_to_pick(graph, on); + if sole_carrier && let (Some(old_pick), Some(new_pick)) = (old_resolved, new_resolved) { + redirect_edges(graph, &moving_edges, old_pick, new_pick); + for &edge in &moving_edges { + if !entering.contains(&edge) { + entering.push(edge); + } + } + entering.sort(); + // Members below in the joined group are now entered through the moved reference: + // they share the merged entry set. + if let StackPlace::Above(target) = place + && graph.is_positioned(target) + { + let t_depth = positions::ref_depth(graph, target); + let mates: Vec<_> = positions::group_members(graph, target) + .into_iter() + .filter(|&mate| mate != entry && positions::ref_depth(graph, mate) <= t_depth) + .filter_map(|mate| { + graph + .positioned_on(mate) + .map(|on| (mate, on, graph.below_of(mate))) + }) + .collect(); + for (mate, m_on, m_below) in mates { + graph.set_position(mate, m_on, &entering, entering.len() > 1, m_below); + } + } + } + graph.set_position(entry, on, &entering, entering.len() > 1, below); +} + +/// Re-point the captured `edges` from `from` onto `to`, keeping each edge's parent number so its +/// group statement follows the name. Edges already rewired elsewhere are left alone. +pub(crate) fn redirect_edges( + graph: &mut GraphEditor, + edges: &[Edge], + from: PickIndex, + to: PickIndex, +) { + if from == to { + return; + } + for &(child, parent_number) in edges { + if graph.parents(child).get(parent_number) == Some(&from) { + graph.replace_parent(child, parent_number, to); + } + } +} + +/// The mate a reference landing at `depth` on `on` (resolved) sits on โ€” the member at +/// `depth - 1`, excluding `exclude`, lowest entry id on a tie. +fn mate_below_depth( + graph: &GraphEditor, + exclude: RefIndex, + on: PickIndex, + depth: usize, +) -> Option { + if depth == 0 { + return None; + } + let pick = positions::resolve_to_pick(graph, on)?; + graph + .positioned_refs() + .filter(|&mate| { + mate != exclude + && positions::resolve_to_pick(graph, mate) == Some(pick) + && positions::ref_depth(graph, mate) + 1 == depth + }) + .min() +} + +/// Re-point the reference at `entry` at the commit `onto` โ€” `git update-ref` as a position +/// move. Its entering edges and the members stacked above move with it; members below lose +/// their entering edges and become roots at the old pick. An unplaced reference is placed as +/// a fresh root; one already resolving there just refreshes its stored `on`. +pub(crate) fn repoint_ref(graph: &mut GraphEditor, entry: RefIndex, onto: PickIndex) { + if !graph.is_positioned(entry) { + place_ref(graph, entry, StackPlace::Root(onto)); + return; + } + match positions::resolve_to_pick(graph, entry) { + Some(old_pick) if old_pick != onto => { + // Snapshot the entering edges before re-pointing them โ€” the derived read tracks + // live edges. + let entering = positions::edges_through(graph, entry); + redirect_edges(graph, &entering, old_pick, onto); + // At the destination the reference sits on whatever holds the depth below it + // there, or directly on the pick when that stack doesn't exist. + let below = mate_below_depth(graph, entry, onto, positions::ref_depth(graph, entry)); + // Carried = the below-subtree stacked on the reference. Depth-tied siblings and + // the walk underneath stay at the old pick, though group mates lose their entering + // edges (those move with `entry`) and become roots there. + let mut carried = vec![entry]; + let mut i = 0; + while i < carried.len() { + let current = carried[i]; + i += 1; + let dependents: Vec<_> = graph + .positioned_refs() + .filter(|&mate| { + graph.below_of(mate) == Some(current) + && !carried.contains(&mate) + && graph.is_reference(mate) + }) + .collect(); + carried.extend(dependents); + } + let mates: Vec<_> = positions::group_members(graph, entry) + .into_iter() + .filter(|&mate| mate != entry && !carried.contains(&mate)) + .filter_map(|mate| { + graph + .positioned_on(mate) + .map(|on| (mate, on, graph.below_of(mate))) + }) + .collect(); + for (mate, m_on, m_below) in mates { + graph.set_position(mate, m_on, &[], false, m_below); + } + for &mate in &carried[1..] { + graph.rekey_position(mate, onto); + } + // Re-classify against `onto`'s final edges โ€” the old `Edges` statement may not + // exist there. + let ambiguous = graph.ambiguous_of(entry); + graph.set_position(entry, onto, &entering, ambiguous, below); + } + _ => { + graph.rekey_position(entry, onto); + } + } +} + +/// Remove the reference at `entry` from its group: members above close the gap and it becomes +/// a root at its current pick. With `drop_edges` the edges that entered through it are removed +/// outright; otherwise they stay on the pick for a follow-up reconnect to rewire. +pub(crate) fn unhook_ref(graph: &mut GraphEditor, entry: RefIndex, drop_edges: bool) { + let Some(unhooked_on) = graph.positioned_on(entry) else { + return; + }; + let unhooked_below = graph.below_of(entry); + // Mates that sat on the unhooked reference settle onto what it sat on. + let rehang: Vec<_> = positions::group_members(graph, entry) + .into_iter() + .filter(|&mate| mate != entry && graph.below_of(mate) == Some(entry)) + .collect(); + for mate in rehang { + graph.set_below(mate, unhooked_below); + } + if drop_edges && let Some(pick) = positions::resolve_to_pick(graph, entry) { + let mut edges = positions::edges_through(graph, entry); + edges.sort_unstable(); + // Descending parent numbers per child: a removal shifts only the parent numbers above it, so every + // pending `(child, parent number)` name below stays exact. + for (child, parent_number) in edges.into_iter().rev() { + if graph.parents(child).get(parent_number) == Some(&pick) { + graph.remove_parent(child, parent_number); + } + } + } + graph.set_position(entry, unhooked_on, &[], false, unhooked_below); +} + +/// Move the stack slice led by `lead_ref` โ€” it and its below-subtree in its group on +/// `source_pick` โ€” onto `dest`: the lead lands at the bottom, each member re-classifies +/// against its own edges at the destination, and stored ambiguity is preserved. +pub(crate) fn transfer_stack( + graph: &mut GraphEditor, + lead_ref: RefIndex, + source_pick: PickIndex, + dest: PickIndex, +) { + if !graph.is_positioned(lead_ref) { + return; + } + let lead_entering = positions::edges_through(graph, lead_ref); + let mut moves = vec![lead_ref]; + let mut i = 0; + while i < moves.len() { + let current = moves[i]; + i += 1; + let dependents: Vec<_> = graph + .positioned_refs() + .filter(|&entry| { + graph.below_of(entry) == Some(current) + && !moves.contains(&entry) + && positions::resolve_to_pick(graph, entry) == Some(source_pick) + && positions::edges_through(graph, entry) == lead_entering + }) + .collect(); + moves.extend(dependents); + } + for entry in moves { + if !graph.is_positioned(entry) { + continue; + } + let entering = positions::edges_through(graph, entry); + // The lead's old below stays behind; the rest keeps its internal stacking. + let below = (entry != lead_ref).then(|| graph.below_of(entry)).flatten(); + let ambiguous = graph.ambiguous_of(entry); + graph.set_position(entry, dest, &entering, ambiguous, below); + } +} + +/// Carry the slice of the group identified by `entering` on `source_pick` strictly above +/// depth `above_depth` onto `dest` verbatim โ€” same depths, same kinds; only the `on` key +/// changes. The delimiter below the slice stays behind. `entering`/`above_depth` are +/// caller-captured (pre-mutation) coordinates, not live derivations. +pub(crate) fn carry_stack_above( + graph: &mut GraphEditor, + source_pick: PickIndex, + entering: &[(PickIndex, usize)], + above_depth: usize, + dest: PickIndex, +) { + let moves: Vec<_> = graph + .positioned_refs() + .filter(|&entry| { + positions::resolve_to_pick(graph, entry) == Some(source_pick) + && positions::edges_through(graph, entry) == entering + && positions::ref_depth(graph, entry) > above_depth + }) + .collect(); + for &entry in &moves { + graph.rekey_position(entry, dest); + } + // The slice bottom sat on the delimiter left behind; at the destination it sits on + // whatever holds the depth below it there. + for &entry in &moves { + if graph.below_of(entry).is_some_and(|b| !moves.contains(&b)) { + let mate = mate_below_depth(graph, entry, dest, positions::ref_depth(graph, entry)); + graph.set_below(entry, mate); + } + } +} + +/// Stack every reference on `source_pick` above `top` (a reference on another pick), the +/// whole tower re-placed behind `bridge_pick`'s full incoming edge set โ€” the bridged edges +/// now descending into the joined group. Returns false (graph untouched) when `top` holds +/// no position. +pub(crate) fn land_stack_above( + graph: &mut GraphEditor, + source_pick: PickIndex, + top: RefIndex, + bridge_pick: PickIndex, +) -> bool { + let Some(top_on) = graph.positioned_on(top) else { + return false; + }; + let bridge = positions::edges_into(graph, bridge_pick); + let top_depth = positions::ref_depth(graph, top); + let top_below = graph.below_of(top); + graph.set_position(top, top_on, &bridge, bridge.len() > 1, top_below); + + let moves: Vec<_> = graph + .positioned_refs() + .filter(|&entry| positions::resolve_to_pick(graph, entry) == Some(source_pick)) + .map(|entry| { + ( + entry, + positions::ref_depth(graph, entry), + graph.below_of(entry), + ) + }) + .collect(); + for (entry, depth, below) in moves { + // Bottom members (they sat on the source pick) now sit on whatever holds the depth + // below their landing spot โ€” `top` when it lives on the bridge pick, its stand-in + // there otherwise. + let below = + below.or_else(|| mate_below_depth(graph, entry, bridge_pick, depth + top_depth + 1)); + graph.set_position(entry, bridge_pick, &bridge, bridge.len() > 1, below); + } + true +} + +/// Re-key every reference whose `on` no longer resolves (it sat on removed picks) onto +/// `onto`, positions carried verbatim โ€” dangling references follow where the commit's +/// place went; their entering edges stay. +pub(crate) fn readopt_dangling_refs(graph: &mut GraphEditor, onto: PickIndex) { + let dangling: Vec<_> = graph + .positioned_refs() + .filter(|&entry| positions::resolve_to_pick(graph, entry).is_none()) + .collect(); + for entry in dangling { + graph.rekey_position(entry, onto); + } +} + +/// Which side of `at_ref` a group split leaves with the lower part. +#[derive(Clone, Copy)] +pub(crate) enum SplitBoundary { + /// Members strictly above the ref move up; the ref stays with the lower part. + Above, + /// The ref and members above it move up; only members below stay. + At, +} + +/// The result of splitting a group around an interposed pick. +pub(crate) struct GroupSplit { + /// The members left behind, with their pre-split `(on, below)` โ€” settle them with + /// [`settle_group_lower`] once the edge entering the lower part is known. + pub lower: Vec<(RefIndex, PickIndex, Option)>, + /// Whether any member moved onto the upper entry. When none did, `at_ref` was the top + /// of its group, so the group's carried edges belong to the caller's new pick. + pub moved_any: bool, +} + +/// Split the group at `at_ref` around a pick interposed into it: members on the upper side of +/// `boundary` re-key onto `upper` (carry kinds verbatim, boundary member at the bottom); the +/// lower members are returned untouched for the caller to settle. +pub(crate) fn split_group( + graph: &mut GraphEditor, + at_ref: RefIndex, + boundary: SplitBoundary, + upper: PickIndex, +) -> GroupSplit { + if !graph.is_positioned(at_ref) { + return GroupSplit { + lower: Vec::new(), + moved_any: false, + }; + } + let members: Vec<_> = positions::group_members(graph, at_ref) + .into_iter() + .filter_map(|entry| { + graph + .positioned_on(entry) + .map(|on| (entry, on, graph.below_of(entry))) + }) + .collect(); + // The upper side is the below-subtree on the moving side โ€” depth-tied siblings from other + // stacks can share the group's entering edges but hang elsewhere and stay. + let mut moved = vec![at_ref]; + let mut i = 0; + while i < moved.len() { + let current = moved[i]; + i += 1; + let dependents: Vec<_> = members + .iter() + .filter(|(entry, _, below)| *below == Some(current) && !moved.contains(entry)) + .map(|(entry, ..)| *entry) + .collect(); + moved.extend(dependents); + } + if matches!(boundary, SplitBoundary::Above) { + moved.remove(0); + } + let mut lower = Vec::new(); + let mut boundary_below = None; + for (entry, on, below) in members { + if moved.contains(&entry) { + graph.rekey_position(entry, upper); + if below.is_none_or(|b| !moved.contains(&b)) { + // The boundary member lands at the bottom; its old below stays on the + // lower side. + boundary_below = below; + graph.set_below(entry, None); + } + } else { + lower.push((entry, on, below)); + } + } + // References stacked on the moved slice but not moving with it (cross-group roots, e.g. a + // remote above the moved tip) settle onto what the slice sat on. + let stranded: Vec<_> = graph + .positioned_refs() + .filter(|&entry| { + !moved.contains(&entry) && graph.below_of(entry).is_some_and(|b| moved.contains(&b)) + }) + .collect(); + for entry in stranded { + graph.set_below(entry, boundary_below); + } + GroupSplit { + lower, + moved_any: !moved.is_empty(), + } +} + +/// Settle the lower part of a split group: each member keeps its `on` and stacking but is now +/// entered through `edge` โ€” the edge descending from the interposed pick. +pub(crate) fn settle_group_lower( + graph: &mut GraphEditor, + lower: &[(RefIndex, PickIndex, Option)], + edge: (PickIndex, usize), +) { + for &(entry, on, below) in lower { + graph.set_position(entry, on, &[edge], false, below); + } +} From a2d3aaedee340020268715939110fbacc16a57cc Mon Sep 17 00:00:00 2001 From: Mattias Granlund Date: Sun, 12 Jul 2026 11:30:06 +0300 Subject: [PATCH 07/11] One graph as data: switch display and rebase onto the CommitGraph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The switch-over. lib.rs declares the new modules, Workspace::from_head/ from_tip/from_seeds become the API, the editor is created from the CommitGraph, and everything downstream re-keys by name and commit id: repository โ”€โ”€walkโ”€โ”€โ–ถ CommitGraph (+ ref layout) โ”€โ”€projectโ”€โ”€โ–ถ Workspace โ–ฒ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ but-rebase edits โ—€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ What to read carefully (~3-4k lines of src): - but-graph: the lib.rs rewiring, projection mod/stack, debug - but-rebase: creation/mutate/materialize now speak arena; membership floods over PICKS, so a shared ref can no longer glue two stacks - but-workspace: segment ids leave every operation surface โ€” anchors, checkout, push status, stack tips, merge-bases all go by name/commit id What to skim: - snapshot re-records: renumber- or stack-order-only, classifier-verified; each deliberate behavior change is enumerated in the PR description - downstream consumers (but-api, CLI, actions, oplog): mechanical re-keys The old pipeline's files are still in the tree but no longer declared โ€” nothing compiles them from here on (the zero-warning workspace check is the proof). They're deleted in the next PR, so this diff stays about the new wiring rather than the funeral. Also in here: the workspace goes petgraph-free. --- .gitignore | 1 + Cargo.lock | 7 +- Cargo.toml | 4 - apps/desktop/vite.config.ts | 2 + crates/WORKSPACE_MODEL.md | 6 +- crates/but-action/src/reword.rs | 2 +- crates/but-action/src/simple.rs | 5 +- crates/but-api/src/branch.rs | 53 +- crates/but-api/src/commit/amend.rs | 5 +- crates/but-api/src/commit/create.rs | 10 +- crates/but-api/src/commit/discard_commit.rs | 16 +- crates/but-api/src/commit/insert_blank.rs | 10 +- crates/but-api/src/commit/json.rs | 2 +- crates/but-api/src/commit/move_changes.rs | 11 +- crates/but-api/src/commit/move_commit.rs | 14 +- crates/but-api/src/commit/reword.rs | 5 +- crates/but-api/src/commit/squash.rs | 5 +- crates/but-api/src/commit/uncommit.rs | 52 +- crates/but-api/src/land/mod.rs | 4 +- crates/but-api/src/land/reconcile.rs | 4 +- crates/but-api/src/legacy/absorb.rs | 3 +- crates/but-api/src/legacy/stack.rs | 3 +- crates/but-api/src/legacy/virtual_branches.rs | 9 +- crates/but-api/src/legacy/workspace.rs | 21 +- crates/but-api/src/resolve/apply.rs | 5 +- crates/but-api/src/workspace.rs | 25 +- crates/but-api/src/workspace_state.rs | 84 +- crates/but-api/tests/api/branch_checkout.rs | 24 +- crates/but-api/tests/api/support.rs | 5 +- crates/but-core/src/ref_metadata.rs | 2 +- crates/but-ctx/src/lib.rs | 7 +- crates/but-debug/src/args.rs | 3 - .../but-debug/src/command/dump/diagnostics.rs | 2 +- crates/but-debug/src/command/graph.rs | 21 +- crates/but-debug/src/command/revision.rs | 52 +- crates/but-feedback/src/lib.rs | 18 +- crates/but-graph/Cargo.toml | 4 +- crates/but-graph/src/debug.rs | 853 ++++----- crates/but-graph/src/init/overlay.rs | 533 ------ crates/but-graph/src/init/remotes.rs | 71 - crates/but-graph/src/init/types.rs | 604 ------ crates/but-graph/src/init/walk/tests.rs | 119 -- crates/but-graph/src/lib.rs | 388 +--- .../projection/{workspace => }/api/legacy.rs | 50 +- .../src/projection/{workspace => }/api/mod.rs | 258 ++- crates/but-graph/src/projection/mod.rs | 455 ++++- crates/but-graph/src/projection/stack.rs | 304 +-- .../src/projection/workspace/api/queries.rs | 165 -- crates/but-graph/tests/fixtures/scenarios.sh | 56 + crates/but-graph/tests/fixtures/shared.sh | 11 +- crates/but-graph/tests/graph/main.rs | 4 +- .../tests/graph/{init => walk}/mod.rs | 1389 ++++++-------- .../tests/graph/{init => walk}/utils.rs | 9 +- .../merge_base_with_target_branch.rs | 24 +- crates/but-graph/tests/graph/workspace/mod.rs | 1 - .../tests/graph/workspace/remote_name.rs | 76 +- .../workspace/resolved_target_commit_id.rs | 31 +- crates/but-hunk-assignment/src/lib.rs | 67 +- crates/but-meta/src/legacy/mod.rs | 79 +- .../tests/meta/ref_metadata_legacy.rs | 16 +- crates/but-rebase/Cargo.toml | 1 - crates/but-rebase/docs/commit_parentage.md | 4 +- .../but-rebase/docs/merge_commit_changes.md | 6 +- .../src/graph_rebase/cherry_pick.rs | 165 +- crates/but-rebase/src/graph_rebase/commit.rs | 32 +- .../but-rebase/src/graph_rebase/creation.rs | 508 +++-- .../src/graph_rebase/materialize.rs | 77 +- .../src/graph_rebase/merge_commit_changes.rs | 58 +- crates/but-rebase/src/graph_rebase/mod.rs | 561 +++--- crates/but-rebase/src/graph_rebase/mutate.rs | 1691 ++++++++++------- .../but-rebase/src/graph_rebase/ordering.rs | 52 +- crates/but-rebase/src/graph_rebase/rebase.rs | 477 +++-- .../but-rebase/src/graph_rebase/selector.rs | 210 ++ crates/but-rebase/src/graph_rebase/testing.rs | 422 ++-- .../but-rebase/src/graph_rebase/traverse.rs | 362 +++- crates/but-rebase/src/graph_rebase/util.rs | 153 +- .../but-rebase/src/graph_rebase/workspace.rs | 488 +++-- crates/but-rebase/src/lib.rs | 17 - crates/but-rebase/src/merge.rs | 37 +- .../tests/rebase/graph_rebase/change_id.rs | 13 +- .../graph_rebase/conflictable_restriction.rs | 64 +- .../tests/rebase/graph_rebase/disconnect.rs | 428 ++--- .../tests/rebase/graph_rebase/edge.rs | 77 +- .../rebase/graph_rebase/editor_creation.rs | 504 +++-- .../rebase/graph_rebase/graph_workspace.rs | 56 +- .../tests/rebase/graph_rebase/insert.rs | 112 +- .../{insert_segment.rs => insert_range.rs} | 315 ++- .../tests/rebase/graph_rebase/materialize.rs | 149 +- .../graph_rebase/merge_commit_changes.rs | 93 +- .../tests/rebase/graph_rebase/mod.rs | 2 +- .../order_commit_selectors_by_parentage.rs | 77 +- .../rebase/graph_rebase/rebase_identities.rs | 137 +- .../tests/rebase/graph_rebase/replace.rs | 100 +- .../tests/rebase/graph_rebase/sha256.rs | 106 +- .../graph_rebase/signing_preferences.rs | 123 +- .../workspace_commit_behaviour.rs | 112 +- crates/but-rebase/tests/rebase/main.rs | 5 +- crates/but-testsupport/Cargo.toml | 7 + crates/but-testsupport/src/graph.rs | 297 ++- crates/but-testsupport/src/lib.rs | 2 +- crates/but-testsupport/src/sandbox.rs | 23 +- crates/but-transaction/Cargo.toml | 1 + crates/but-transaction/src/lib.rs | 99 +- crates/but-transaction/src/tests.rs | 3 +- crates/but-workspace/src/branch/apply.rs | 156 +- .../src/branch/create_reference.rs | 38 +- .../branch/integrate_branch_upstream/mod.rs | 37 +- .../branch/integrate_branch_upstream/plan.rs | 27 +- crates/but-workspace/src/branch/mod.rs | 25 +- .../but-workspace/src/branch/move_branch.rs | 173 +- .../src/branch/remove_reference.rs | 12 +- crates/but-workspace/src/branch/unapply.rs | 166 +- crates/but-workspace/src/changeset.rs | 101 +- .../but-workspace/src/commit/commit_amend.rs | 10 +- .../but-workspace/src/commit/commit_create.rs | 14 +- .../src/commit/discard_commit.rs | 12 +- .../src/commit/insert_blank_commit.rs | 6 +- crates/but-workspace/src/commit/mod.rs | 123 +- .../but-workspace/src/commit/move_changes.rs | 10 +- .../but-workspace/src/commit/move_commit.rs | 29 +- crates/but-workspace/src/commit/reword.rs | 6 +- .../src/commit/squash_commits.rs | 24 +- .../src/commit/uncommit_changes.rs | 39 +- crates/but-workspace/src/divergence.rs | 28 +- .../but-workspace/src/graph_manipulation.rs | 137 +- crates/but-workspace/src/legacy/head.rs | 4 +- crates/but-workspace/src/legacy/push.rs | 15 +- crates/but-workspace/src/legacy/stacks.rs | 6 +- crates/but-workspace/src/lib.rs | 24 +- crates/but-workspace/src/ref_info.rs | 125 +- crates/but-workspace/src/ui/diff.rs | 6 +- crates/but-workspace/src/ui/mod.rs | 2 +- crates/but-workspace/src/ui/ref_info.rs | 17 +- .../but-workspace/src/upstream_integration.rs | 108 +- crates/but-workspace/src/workspace.rs | 48 +- crates/but-workspace/src/worktree.rs | 20 +- .../tests/fixtures/scenario/shared.sh | 11 +- .../single-branch-commit-with-empties.sh | 20 + ...ref-ws-commit-single-stack-double-stack.sh | 4 +- .../ws-with-empty-stack-target-advanced.sh | 2 +- .../fixtures/scenario/ws-with-empty-stack.sh | 4 +- .../tests/workspace/branch/apply_unapply.rs | 463 ++--- .../workspace/branch/create_reference.rs | 269 ++- .../branch/integrate_branch_upstream.rs | 204 +- .../tests/workspace/branch/move_branch.rs | 581 +++--- .../workspace/branch/remove_reference.rs | 20 +- .../tests/workspace/branch/tear_off_branch.rs | 162 +- .../tests/workspace/commit/commit_amend.rs | 15 +- .../tests/workspace/commit/commit_create.rs | 28 +- .../tests/workspace/commit/discard_commit.rs | 98 +- .../workspace/commit/insert_blank_commit.rs | 25 +- .../tests/workspace/commit/mod.rs | 45 +- .../tests/workspace/commit/move_changes.rs | 25 +- .../tests/workspace/commit/move_commit.rs | 299 ++- .../tests/workspace/commit/reword.rs | 15 +- .../tests/workspace/commit/squash_commits.rs | 140 +- .../workspace/commit/uncommit_changes.rs | 82 +- crates/but-workspace/tests/workspace/graph.rs | 88 +- .../tests/workspace/ref_info/mod.rs | 32 +- .../journey/exhaustive_with_squash_merges.rs | 106 -- .../journey/integrate_with_merges.rs | 64 - .../journey/integrate_with_rebase.rs | 32 - .../with_workspace_commit/journey/mod.rs | 2 +- .../ref_info/with_workspace_commit/legacy.rs | 72 +- .../ref_info/with_workspace_commit/mod.rs | 576 ++---- crates/but-workspace/tests/workspace/ui.rs | 5 +- .../tests/workspace/upstream_integration.rs | 368 ++-- .../but-workspace/tests/workspace/worktree.rs | 48 +- crates/but-worktrees/src/integrate.rs | 10 +- crates/but/src/command/commit/move.rs | 3 +- crates/but/src/command/legacy/commit.rs | 3 +- crates/but/src/command/legacy/commit2.rs | 3 +- crates/but/src/command/legacy/move2.rs | 2 +- crates/but/src/command/legacy/rub/amend.rs | 4 +- crates/but/src/command/legacy/rub/mod.rs | 3 +- crates/but/src/command/legacy/setup.rs | 2 +- crates/but/src/command/legacy/status/json.rs | 8 +- crates/but/src/command/legacy/status/mod.rs | 39 +- .../command/legacy/status/tui/operations.rs | 3 +- .../legacy/status/tui/tests/move_tests.rs | 2 +- .../snapshots/moving_multiple_commits_001.svg | 29 +- .../snapshots/moving_multiple_commits_002.svg | 29 +- .../snapshots/moving_multiple_commits_003.svg | 29 +- .../snapshots/moving_multiple_commits_004.svg | 29 +- .../snapshots/moving_multiple_commits_005.svg | 29 +- .../command/legacy/status/tui/tests/utils.rs | 7 + .../src/command/legacy/workspace_target.rs | 5 +- crates/but/src/id/tests.rs | 21 +- crates/but/src/legacy/workspace.rs | 2 +- crates/but/src/utils/metrics.rs | 7 +- crates/but/tests/but/command/branch/new.rs | 24 + crates/but/tests/but/command/branch/update.rs | 86 +- crates/but/tests/but/command/commit.rs | 20 +- crates/but/tests/but/command/move.rs | 8 +- crates/but/tests/but/command/move2.rs | 36 +- crates/but/tests/but/command/squash.rs | 16 +- crates/but/tests/but/command/teardown.rs | 22 +- crates/but/tests/but/command/worktree.rs | 8 +- crates/but/tests/but/journey.rs | 11 +- crates/but/tests/fixtures/scenario/shared.sh | 22 +- crates/gitbutler-branch-actions/src/branch.rs | 2 +- .../src/branch_manager/branch_creation.rs | 31 +- .../branch-actions/virtual_branches/mod.rs | 11 +- crates/gitbutler-edit-mode/src/lib.rs | 21 +- crates/gitbutler-oplog/src/oplog.rs | 2 + e2e/playwright/tests/branches.spec.ts | 4 + .../but-sdk/src/generated/graph/index.d.ts | 4 +- .../but-sdk/src/generated/linear/index.d.ts | 4 +- 208 files changed, 9241 insertions(+), 11108 deletions(-) delete mode 100644 crates/but-graph/src/init/overlay.rs delete mode 100644 crates/but-graph/src/init/remotes.rs delete mode 100644 crates/but-graph/src/init/types.rs delete mode 100644 crates/but-graph/src/init/walk/tests.rs rename crates/but-graph/src/projection/{workspace => }/api/legacy.rs (62%) rename crates/but-graph/src/projection/{workspace => }/api/mod.rs (66%) delete mode 100644 crates/but-graph/src/projection/workspace/api/queries.rs rename crates/but-graph/tests/graph/{init => walk}/mod.rs (58%) rename crates/but-graph/tests/graph/{init => walk}/utils.rs (95%) create mode 100644 crates/but-rebase/src/graph_rebase/selector.rs rename crates/but-rebase/tests/rebase/graph_rebase/{insert_segment.rs => insert_range.rs} (67%) create mode 100644 crates/but-workspace/tests/fixtures/scenario/single-branch-commit-with-empties.sh diff --git a/.gitignore b/.gitignore index 2684ccf77e2..a4b6e698b26 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ dist-ssr .turbo build +!crates/*/src/build/ .svelte-kit package !.env.example diff --git a/Cargo.lock b/Cargo.lock index 33668d29a80..99181a463dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1325,14 +1325,13 @@ dependencies = [ "boolean-enums", "bstr", "but-core", - "but-graph", "but-meta", "but-testsupport", "gitbutler-reference", "gix", "itertools", - "petgraph", "snapbox", + "termtree", "tracing", ] @@ -1540,7 +1539,6 @@ dependencies = [ "but-testsupport", "gitbutler-reference", "gix", - "petgraph", "sapling-renderdag", "schemars 1.2.1", "serde", @@ -1640,6 +1638,7 @@ dependencies = [ "gix", "gix-testtools", "regex", + "sapling-renderdag", "shell-words", "snapbox", "temp-env", @@ -1670,6 +1669,7 @@ dependencies = [ "but-core", "but-ctx", "but-db", + "but-graph", "but-oplog", "but-rebase", "but-testsupport", @@ -7639,7 +7639,6 @@ dependencies = [ "fixedbitset 0.5.7", "hashbrown 0.15.5", "indexmap 2.14.0", - "serde", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 46077a6dbee..4f70df89623 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -272,10 +272,6 @@ bitflags = "2.11.1" notify = "8.2.0" snapbox = { version = "0.6.23", features = ["json"] } url = "2.5.7" -petgraph = { version = "0.8.3", default-features = false, features = [ - "stable_graph", - "std", -] } schemars = { version = "1.2.0", default-features = false, features = [ "std", diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 274ff639e52..e21f7fc793d 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -45,6 +45,8 @@ export default defineConfig({ fs: { strict: false, }, + watch: process.env.VITE_DISABLE_WATCH ? null : undefined, + hmr: process.env.VITE_DISABLE_WATCH ? false : undefined, }, optimizeDeps: { // Exclude local packages from pre-bundling diff --git a/crates/WORKSPACE_MODEL.md b/crates/WORKSPACE_MODEL.md index e0e8efcfdba..6b7cf72f892 100644 --- a/crates/WORKSPACE_MODEL.md +++ b/crates/WORKSPACE_MODEL.md @@ -90,11 +90,13 @@ Use it for state/query questions such as: - which commits belong under a ref or stack-like UI grouping? - what branch/ref relationships exist before deciding what to display or mutate? +Construction: production code asks for the projection directly โ€” `but_graph::Workspace::from_head()`, `Workspace::from_tip()`, `workspace.redo()`, or `SuccessfulRebase::overlayed_workspace()` after an editor rebase. The graph rides along as `Workspace::graph`; only tests and debug tooling build a bare `Graph`. + Caveats: - The graph is segment/bucket based because older UI concerns influenced it. - It can encode ordering information Git itself does not represent, especially around refs. -- Merge parent order may not always be reliable; be careful with first-parent traversal or UI that assumes the first parent is the mainline. +- Within `but_graph`, parent order is preserved and authoritative: parent arrays are ordered, and a segment's outgoing connections follow them. Whether the *first* parent is the user's "mainline" is still a workflow assumption โ€” question it at the product level, not the graph level. ## Workspace projection and refinfo @@ -194,7 +196,7 @@ Ask this for both read/query code and mutation code: ## Examples / starting points -- Graph construction and workspace projection: `crates/but-graph/tests/graph/init/with_workspace.rs`, especially `workspace_with_stack_and_local_target()` and `workspace_projection_with_advanced_stack_tip()`, shows `Graph::from_head()`, `validated()`, `into_workspace()`, and snapshot-backed graph/projection expectations. +- Graph construction and workspace projection: `crates/but-graph/tests/graph/walk/with_workspace.rs`, especially `workspace_with_stack_and_local_target()` and `workspace_projection_with_advanced_stack_tip()`, shows `Graph::from_head()`, `validated()`, `into_workspace()`, and snapshot-backed graph/projection expectations. - Target ref and target commit semantics: `crates/but-graph/tests/graph/workspace/resolved_target_commit_id.rs`, especially `prefers_target_commit_over_target_ref()` and `returns_none_with_only_extra_target()`, shows cases where target commit metadata, target refs, and extra traversal targets intentionally differ. - Graph editor mutation patterns: `crates/but-rebase/tests/rebase/graph_rebase/replace.rs` and `crates/but-rebase/tests/rebase/graph_rebase/insert.rs` show selecting commits, replacing/inserting steps, checking `overlayed_graph()`, and materializing once. - Workspace mutation call sites layered over the graph editor: `crates/but-workspace/tests/workspace/commit/move_commit.rs` shows creating an editor, calling `but_workspace::commit::move_commit`, materializing, refreshing workspace state, and asserting ref movement. diff --git a/crates/but-action/src/reword.rs b/crates/but-action/src/reword.rs index 59d14bf5efc..028aebf0265 100644 --- a/crates/but-action/src/reword.rs +++ b/crates/but-action/src/reword.rs @@ -41,7 +41,7 @@ pub fn commit( bail!("commit message cannot be empty"); } - let editor = Editor::create(ws, meta, repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), meta, repo)?; let (rebase, edited_commit_selector) = but_workspace::commit::reword(editor, input.commit_id, message.as_bytes().as_bstr())?; let new_commit_id = rebase.lookup_pick(edited_commit_selector)?; diff --git a/crates/but-action/src/simple.rs b/crates/but-action/src/simple.rs index 1cafba590f8..89d6bd4d860 100644 --- a/crates/but-action/src/simple.rs +++ b/crates/but-action/src/simple.rs @@ -4,8 +4,7 @@ use anyhow::{Context as _, anyhow}; use but_core::{DiffSpec, RefMetadata, ref_metadata::StackId, sync::RepoExclusive}; use but_db::DbHandle; use but_rebase::graph_rebase::{ - Editor, LookupStep as _, - mutate::{InsertSide, RelativeToRef}, + Editor, LookupStep as _, mutate::InsertSide, selector::RelativeToRef, }; use crate::Outcome; @@ -114,7 +113,7 @@ pub(crate) fn handle_changes( let full_ref_name: gix::refs::FullName = format!("refs/heads/{stack_branch_name}").try_into()?; - let editor = Editor::create(ws, meta, repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), meta, repo)?; let outcome = but_workspace::commit::commit_create( editor, diff_specs, diff --git a/crates/but-api/src/branch.rs b/crates/but-api/src/branch.rs index 2265e57e7d1..8cae1fa24d1 100644 --- a/crates/but-api/src/branch.rs +++ b/crates/but-api/src/branch.rs @@ -933,11 +933,7 @@ pub fn branch_remove_with_perm( }; let deleted_meta = meta.remove(ref_name.as_ref())?; if deleted_ref || deleted_meta { - let new_ws = ws - .graph - .redo_traversal_with_overlay(&repo, &meta, Default::default())? - .into_workspace()?; - *ws = new_ws; + *ws = ws.redo(&repo, &meta, Default::default())?; true } else { false @@ -1454,12 +1450,11 @@ pub fn get_initial_branch_integration( ) -> anyhow::Result { let mut meta = ctx.meta()?; let (_guard, repo, ws, _) = ctx.workspace_and_db()?; - let mut ws = ws.clone(); let strategy = strategy .map(BranchIntegrationStrategy::from) .unwrap_or_default(); but_workspace::branch::integrate_branch_upstream::get_initial_integration_steps_for_branch( - branch, strategy, &mut ws, &mut meta, &repo, + branch, strategy, &ws, &mut meta, &repo, ) } @@ -1507,14 +1502,14 @@ pub fn apply_branch_integration_with_perm( let rebase = but_workspace::branch::integrate_branch_with_steps( branch, integration, - &mut ws, + &ws, &mut meta, &repo, )?; Ok(IntegrateBranchResult { workspace: WorkspaceState::from_successful_rebase_with_db( - rebase, &repo, dry_run, &db, + &mut ws, rebase, &repo, dry_run, &db, )?, }) }, @@ -1570,16 +1565,17 @@ pub fn move_branch_with_perm( |ctx, perm| { let mut meta = ctx.meta()?; let (repo, mut ws, db) = ctx.workspace_mut_and_db_with_perm(perm)?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let but_workspace::branch::move_branch::Outcome { rebase, ws_meta, new_tip, branch_stack_order, - } = but_workspace::branch::move_branch(editor, subject_branch, target_branch)?; + } = but_workspace::branch::move_branch(editor, &ws, subject_branch, target_branch)?; let result = MoveBranchResult { workspace: branch_workspace_from_rebase( + &mut ws, rebase, ws_meta, new_tip.as_ref(), @@ -1650,14 +1646,14 @@ pub fn tear_off_branch_with_perm( |ctx, perm| { let mut meta = ctx.meta()?; let (repo, mut ws, db) = ctx.workspace_mut_and_db_with_perm(perm)?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let but_workspace::branch::move_branch::Outcome { rebase, ws_meta, .. - } = but_workspace::branch::tear_off_branch(editor, subject_branch, None)?; + } = but_workspace::branch::tear_off_branch(editor, &ws, subject_branch, None)?; Ok(MoveBranchResult { workspace: branch_workspace_from_rebase( - rebase, ws_meta, None, None, &repo, dry_run, &db, + &mut ws, rebase, ws_meta, None, None, &repo, dry_run, &db, )?, }) }, @@ -1691,8 +1687,10 @@ where result } +#[allow(clippy::too_many_arguments)] fn branch_workspace_from_rebase( - mut rebase: SuccessfulRebase<'_, '_, M>, + workspace: &mut but_graph::Workspace, + mut rebase: SuccessfulRebase<'_, M>, ws_meta: Option, new_tip: Option<&gix::refs::FullName>, branch_stack_order: Option<&[gix::refs::FullName]>, @@ -1707,36 +1705,27 @@ fn branch_workspace_from_rebase( }) .transpose()?; let replaced_commits = rebase.history.commit_mappings(); - let workspace = rebase - .overlayed_graph_with_workspace_overrides(entrypoint, branch_stack_order)? - .into_workspace()?; + let overlay = + rebase.rebase_overlay_with_workspace_overrides(entrypoint, branch_stack_order)?; let (repo, meta) = rebase.repo_and_meta_mut(); - return WorkspaceState::from_workspace_with_db( - &workspace, - meta, - repo, - replaced_commits, - db, - ); + let preview = workspace.redo(repo, meta, overlay)?; + return WorkspaceState::from_workspace_with_db(&preview, meta, repo, replaced_commits, db); } let materialized = rebase.materialize()?; if let Some(order) = branch_stack_order { materialized.meta.set_branch_stack_order(order)?; - let project_meta = materialized.workspace.graph.project_meta.clone(); - materialized - .workspace - .refresh_from_head(repo, &*materialized.meta, project_meta)?; } - if let Some((ws_meta, ref_name)) = ws_meta.zip(materialized.workspace.ref_name()) { + workspace.refresh_from_commit_graph(materialized.arena().clone(), repo, materialized.meta)?; + if let Some((ws_meta, ref_name)) = ws_meta.zip(workspace.ref_name()) { let mut md = materialized.meta.workspace(ref_name)?; *md = ws_meta; - md.set_project_meta(materialized.workspace.graph.project_meta.clone()); + md.set_project_meta(workspace.project_meta().clone()); materialized.meta.set_workspace(&md)?; } WorkspaceState::from_workspace_with_db( - materialized.workspace, + workspace, materialized.meta, repo, materialized.history.commit_mappings(), diff --git a/crates/but-api/src/commit/amend.rs b/crates/but-api/src/commit/amend.rs index 7053040fe09..fb5dea59d90 100644 --- a/crates/but-api/src/commit/amend.rs +++ b/crates/but-api/src/commit/amend.rs @@ -42,7 +42,7 @@ pub(crate) fn commit_amend_only_impl( ) -> anyhow::Result { let mut meta = ctx.meta()?; let (repo, mut ws, db) = ctx.workspace_mut_and_db_with_perm(perm)?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let but_workspace::commit::CommitAmendOutcome { rebase, @@ -53,7 +53,8 @@ pub(crate) fn commit_amend_only_impl( let new_commit = commit_selector .map(|commit_selector| rebase.lookup_pick(commit_selector)) .transpose()?; - let workspace = WorkspaceState::from_successful_rebase_with_db(rebase, &repo, dry_run, &db)?; + let workspace = + WorkspaceState::from_successful_rebase_with_db(&mut ws, rebase, &repo, dry_run, &db)?; Ok(CommitCreateResult { new_commit, diff --git a/crates/but-api/src/commit/create.rs b/crates/but-api/src/commit/create.rs index 9cb05a38f68..1b9bf1fd77a 100644 --- a/crates/but-api/src/commit/create.rs +++ b/crates/but-api/src/commit/create.rs @@ -2,10 +2,7 @@ use crate::WorkspaceState; use but_api_macros::but_api; use but_core::{DiffSpec, DryRun, sync::RepoExclusive}; use but_oplog::legacy::{OperationKind, SnapshotDetails}; -use but_rebase::graph_rebase::{ - Editor, LookupStep as _, - mutate::{InsertSide, RelativeTo}, -}; +use but_rebase::graph_rebase::{Editor, LookupStep as _, mutate::InsertSide, selector::RelativeTo}; use tracing::instrument; use super::types::CommitCreateResult; @@ -59,7 +56,7 @@ pub(crate) fn commit_create_only_impl( ) -> anyhow::Result { let mut meta = ctx.meta()?; let (repo, mut ws, db) = ctx.workspace_mut_and_db_with_perm(perm)?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let but_workspace::commit::CommitCreateOutcome { rebase, @@ -77,7 +74,8 @@ pub(crate) fn commit_create_only_impl( let new_commit = commit_selector .map(|commit_selector| rebase.lookup_pick(commit_selector)) .transpose()?; - let workspace = WorkspaceState::from_successful_rebase_with_db(rebase, &repo, dry_run, &db)?; + let workspace = + WorkspaceState::from_successful_rebase_with_db(&mut ws, rebase, &repo, dry_run, &db)?; Ok(CommitCreateResult { new_commit, diff --git a/crates/but-api/src/commit/discard_commit.rs b/crates/but-api/src/commit/discard_commit.rs index 58f1f008dd7..4c20ac53f30 100644 --- a/crates/but-api/src/commit/discard_commit.rs +++ b/crates/but-api/src/commit/discard_commit.rs @@ -39,11 +39,12 @@ pub fn commit_discard_only_with_perm( ) -> anyhow::Result { let mut meta = ctx.meta()?; let (repo, mut ws, db) = ctx.workspace_mut_and_db_with_perm(perm)?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let rebase = but_workspace::commit::discard_commits(editor, [subject_commit_id])?; - let workspace = WorkspaceState::from_successful_rebase_with_db(rebase, &repo, dry_run, &db)?; + let workspace = + WorkspaceState::from_successful_rebase_with_db(&mut ws, rebase, &repo, dry_run, &db)?; Ok(CommitDiscardResult { discarded_commit: subject_commit_id, @@ -142,12 +143,17 @@ pub fn commit_discard_changes_only_with_perm( let context_lines = ctx.settings.context_lines; let mut meta = ctx.meta()?; let (repo, mut ws, db) = ctx.workspace_mut_and_db_with_perm(perm)?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let outcome = but_workspace::commit::uncommit_changes(editor, commit_id, changes, context_lines)?; - let workspace = - WorkspaceState::from_successful_rebase_with_db(outcome.rebase, &repo, dry_run, &db)?; + let workspace = WorkspaceState::from_successful_rebase_with_db( + &mut ws, + outcome.rebase, + &repo, + dry_run, + &db, + )?; Ok(MoveChangesResult { workspace }) } diff --git a/crates/but-api/src/commit/insert_blank.rs b/crates/but-api/src/commit/insert_blank.rs index 545dbde0a23..82de010c8d0 100644 --- a/crates/but-api/src/commit/insert_blank.rs +++ b/crates/but-api/src/commit/insert_blank.rs @@ -2,10 +2,7 @@ use crate::WorkspaceState; use but_api_macros::but_api; use but_core::{DryRun, sync::RepoExclusive}; use but_oplog::legacy::{OperationKind, SnapshotDetails}; -use but_rebase::graph_rebase::{ - Editor, LookupStep as _, - mutate::{InsertSide, RelativeTo}, -}; +use but_rebase::graph_rebase::{Editor, LookupStep as _, mutate::InsertSide, selector::RelativeTo}; use tracing::instrument; use super::types::CommitInsertBlankResult; @@ -41,12 +38,13 @@ pub(crate) fn commit_insert_blank_only_impl( ) -> anyhow::Result { let mut meta = ctx.meta()?; let (repo, mut ws, db) = ctx.workspace_mut_and_db_with_perm(perm)?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let (rebase, blank_commit_selector) = but_workspace::commit::insert_blank_commit(editor, side, relative_to)?; let new_commit = rebase.lookup_pick(blank_commit_selector)?; - let workspace = WorkspaceState::from_successful_rebase_with_db(rebase, &repo, dry_run, &db)?; + let workspace = + WorkspaceState::from_successful_rebase_with_db(&mut ws, rebase, &repo, dry_run, &db)?; Ok(CommitInsertBlankResult { new_commit, diff --git a/crates/but-api/src/commit/json.rs b/crates/but-api/src/commit/json.rs index 236465880ce..dd28b6ebe5e 100644 --- a/crates/but-api/src/commit/json.rs +++ b/crates/but-api/src/commit/json.rs @@ -357,7 +357,7 @@ pub enum RelativeTo { #[cfg(feature = "export-schema")] but_schemars::register_sdk_type!(RelativeTo); -impl From for but_rebase::graph_rebase::mutate::RelativeTo { +impl From for but_rebase::graph_rebase::selector::RelativeTo { fn from(value: RelativeTo) -> Self { match value { RelativeTo::Commit(commit) => Self::Commit(commit), diff --git a/crates/but-api/src/commit/move_changes.rs b/crates/but-api/src/commit/move_changes.rs index 9827168d08e..0041724c2a6 100644 --- a/crates/but-api/src/commit/move_changes.rs +++ b/crates/but-api/src/commit/move_changes.rs @@ -54,7 +54,7 @@ pub fn commit_move_changes_between_only_with_perm( let context_lines = ctx.settings.context_lines; let mut meta = ctx.meta()?; let (repo, mut ws, db) = ctx.workspace_mut_and_db_with_perm(perm)?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let outcome = but_workspace::commit::move_changes_between_commits( editor, @@ -63,8 +63,13 @@ pub fn commit_move_changes_between_only_with_perm( changes, context_lines, )?; - let workspace = - WorkspaceState::from_successful_rebase_with_db(outcome.rebase, &repo, dry_run, &db)?; + let workspace = WorkspaceState::from_successful_rebase_with_db( + &mut ws, + outcome.rebase, + &repo, + dry_run, + &db, + )?; Ok(MoveChangesResult { workspace }) } diff --git a/crates/but-api/src/commit/move_commit.rs b/crates/but-api/src/commit/move_commit.rs index dec60e7f338..b7086a691d7 100644 --- a/crates/but-api/src/commit/move_commit.rs +++ b/crates/but-api/src/commit/move_commit.rs @@ -1,7 +1,8 @@ use but_api_macros::but_api; use but_core::{DryRun, sync::RepoExclusive}; use but_oplog::legacy::{OperationKind, SnapshotDetails}; -use but_rebase::graph_rebase::mutate::{InsertSide, RelativeTo}; +use but_rebase::graph_rebase::mutate::InsertSide; +use but_rebase::graph_rebase::selector::RelativeTo; use tracing::instrument; use crate::WorkspaceState; @@ -52,12 +53,19 @@ pub fn commit_move_only_with_perm( ) -> anyhow::Result { let mut meta = ctx.meta()?; let (repo, mut ws, db) = ctx.workspace_mut_and_db_with_perm(perm)?; - let editor = but_rebase::graph_rebase::Editor::create(&mut ws, &mut meta, &repo)?; + let editor = but_rebase::graph_rebase::Editor::create( + ws.commit_graph(), + ws.project_meta(), + &mut meta, + &repo, + )?; let rebase = but_workspace::commit::move_commits(editor, subject_commit_ids, relative_to, side)?; Ok(CommitMoveResult { - workspace: WorkspaceState::from_successful_rebase_with_db(rebase, &repo, dry_run, &db)?, + workspace: WorkspaceState::from_successful_rebase_with_db( + &mut ws, rebase, &repo, dry_run, &db, + )?, }) } diff --git a/crates/but-api/src/commit/reword.rs b/crates/but-api/src/commit/reword.rs index 90395a9f633..700d9081d83 100644 --- a/crates/but-api/src/commit/reword.rs +++ b/crates/but-api/src/commit/reword.rs @@ -47,12 +47,13 @@ pub fn commit_reword_only_with_perm( ) -> anyhow::Result { let mut meta = ctx.meta()?; let (repo, mut ws, db) = ctx.workspace_mut_and_db_with_perm(perm)?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let (rebase, edited_commit_selector) = but_workspace::commit::reword(editor, commit_id, message.as_bstr())?; let new_commit = rebase.lookup_pick(edited_commit_selector)?; - let workspace = WorkspaceState::from_successful_rebase_with_db(rebase, &repo, dry_run, &db)?; + let workspace = + WorkspaceState::from_successful_rebase_with_db(&mut ws, rebase, &repo, dry_run, &db)?; Ok(CommitRewordResult { new_commit, diff --git a/crates/but-api/src/commit/squash.rs b/crates/but-api/src/commit/squash.rs index 824b1b19cb2..9c4394a1ac9 100644 --- a/crates/but-api/src/commit/squash.rs +++ b/crates/but-api/src/commit/squash.rs @@ -55,7 +55,7 @@ pub fn commit_squash_only_with_perm( } let mut meta = ctx.meta()?; let (repo, mut ws, db) = ctx.workspace_mut_and_db_with_perm(perm)?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let SquashCommitsOutcome { rebase, commit_selector, @@ -66,7 +66,8 @@ pub fn commit_squash_only_with_perm( how_to_combine_messages, )?; let new_commit = rebase.lookup_pick(commit_selector)?; - let workspace = WorkspaceState::from_successful_rebase_with_db(rebase, &repo, dry_run, &db)?; + let workspace = + WorkspaceState::from_successful_rebase_with_db(&mut ws, rebase, &repo, dry_run, &db)?; Ok(CommitSquashResult { new_commit, diff --git a/crates/but-api/src/commit/uncommit.rs b/crates/but-api/src/commit/uncommit.rs index 315ec2494e6..b095e71b8fd 100644 --- a/crates/but-api/src/commit/uncommit.rs +++ b/crates/but-api/src/commit/uncommit.rs @@ -139,7 +139,7 @@ pub fn commit_uncommit_only_with_perm( None }; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let mut rebase = but_workspace::commit::discard_commits(editor, subject_commit_ids.iter().copied()) @@ -154,19 +154,17 @@ pub fn commit_uncommit_only_with_perm( ) })?; + let preview; let (workspace, replaced_commits, repo, meta) = if dry_run.into() { - let graph = rebase.overlayed_graph()?; + preview = but_workspace::workspace::overlayed_workspace(&ws, &rebase)?; let replaced_commits = rebase.history.commit_mappings(); let (repo, meta) = rebase.repo_and_meta_mut(); - (&mut graph.into_workspace()?, replaced_commits, repo, meta) + (&preview, replaced_commits, repo, meta) } else { let materialized = rebase.materialize_without_checkout()?; - ( - materialized.workspace, - materialized.history.commit_mappings(), - &*repo, - materialized.meta, - ) + let replaced_commits = materialized.history.commit_mappings(); + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; + (&*ws, replaced_commits, &*repo, materialized.meta) }; if let (Some(before_assignments), Some(assign_to)) = (before_assignments, assign_to) { @@ -287,23 +285,21 @@ pub fn commit_uncommit_changes_only_with_perm( None }; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let mut outcome = but_workspace::commit::uncommit_changes(editor, commit_id, changes, context_lines)?; + let preview; let (workspace, replaced_commits, repo, meta) = if dry_run.into() { - let graph = outcome.rebase.overlayed_graph()?; + preview = but_workspace::workspace::overlayed_workspace(&ws, &outcome.rebase)?; let replaced_commits = outcome.rebase.history.commit_mappings(); let (repo, meta) = outcome.rebase.repo_and_meta_mut(); - (&mut graph.into_workspace()?, replaced_commits, repo, meta) + (&preview, replaced_commits, repo, meta) } else { let materialized = outcome.rebase.materialize_without_checkout()?; - ( - materialized.workspace, - materialized.history.commit_mappings(), - &*repo, - materialized.meta, - ) + let replaced_commits = materialized.history.commit_mappings(); + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; + (&*ws, replaced_commits, &*repo, materialized.meta) }; if let (Some(before_assignments), Some(stack_id)) = (before_assignments, assign_to) { @@ -475,7 +471,7 @@ pub fn commit_uncommit_changes_from_commits_only_with_perm( None }; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let workspace_sources = sources .into_iter() .map(|source| but_workspace::commit::UncommitChangesSource { @@ -499,25 +495,23 @@ pub fn commit_uncommit_changes_from_commits_only_with_perm( .collect::>(); let mut rebase = outcome.rebase; + let preview; let (workspace, replaced_commits, repo, meta) = if dry_run.into() { if let Some(rebase) = rebase.as_mut() { - let graph = rebase.overlayed_graph()?; + preview = but_workspace::workspace::overlayed_workspace(&ws, rebase)?; let replaced_commits = rebase.history.commit_mappings(); let (repo, meta) = rebase.repo_and_meta_mut(); - (&mut graph.into_workspace()?, replaced_commits, repo, meta) + (&preview, replaced_commits, repo, meta) } else { - (&mut *ws, BTreeMap::new(), &*repo, &mut meta) + (&*ws, BTreeMap::new(), &*repo, &mut meta) } } else if let Some(rebase) = rebase { let materialized = rebase.materialize_without_checkout()?; - ( - materialized.workspace, - materialized.history.commit_mappings(), - &*repo, - materialized.meta, - ) + let replaced_commits = materialized.history.commit_mappings(); + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; + (&*ws, replaced_commits, &*repo, materialized.meta) } else { - (&mut *ws, BTreeMap::new(), &*repo, &mut meta) + (&*ws, BTreeMap::new(), &*repo, &mut meta) }; if let (Some(before_assignments), Some(stack_id)) = (before_assignments, assign_to) { diff --git a/crates/but-api/src/land/mod.rs b/crates/but-api/src/land/mod.rs index 50313fcf39e..e67539ca297 100644 --- a/crates/but-api/src/land/mod.rs +++ b/crates/but-api/src/land/mod.rs @@ -347,8 +347,8 @@ fn validate_branch_landing( &ws, &repo, but_workspace::ref_info::Options { - project_meta: ws.graph.project_meta.clone(), - traversal: but_graph::init::Options::limited(), + project_meta: ws.project_meta().clone(), + traversal: but_graph::walk::Options::limited(), expensive_commit_info: true, ..Default::default() }, diff --git a/crates/but-api/src/land/reconcile.rs b/crates/but-api/src/land/reconcile.rs index 37305571ec1..bf7c17a2126 100644 --- a/crates/but-api/src/land/reconcile.rs +++ b/crates/but-api/src/land/reconcile.rs @@ -99,8 +99,8 @@ fn bottom_updates( &ws, &repo, but_workspace::ref_info::Options { - project_meta: ws.graph.project_meta.clone(), - traversal: but_graph::init::Options::limited(), + project_meta: ws.project_meta().clone(), + traversal: but_graph::walk::Options::limited(), expensive_commit_info: false, ..Default::default() }, diff --git a/crates/but-api/src/legacy/absorb.rs b/crates/but-api/src/legacy/absorb.rs index 5f804c858bd..acf674c65c3 100644 --- a/crates/but-api/src/legacy/absorb.rs +++ b/crates/but-api/src/legacy/absorb.rs @@ -12,7 +12,8 @@ use but_hunk_dependency::ui::{ HunkDependencies, HunkLock, HunkLockTarget, hunk_dependencies_for_workspace_changes_by_worktree_dir, }; -use but_rebase::graph_rebase::mutate::{InsertSide, RelativeTo}; +use but_rebase::graph_rebase::mutate::InsertSide; +use but_rebase::graph_rebase::selector::RelativeTo; use but_workspace::ui::{BranchDetails, StackDetails}; use gitbutler_oplog::{ OplogExt, diff --git a/crates/but-api/src/legacy/stack.rs b/crates/but-api/src/legacy/stack.rs index 8161ee96136..33389c95421 100644 --- a/crates/but-api/src/legacy/stack.rs +++ b/crates/but-api/src/legacy/stack.rs @@ -197,8 +197,9 @@ pub fn create_branch( }, ) .or_else(|| { + // An anonymous segment exists to hold commits, so its tip is right here. Some(but_workspace::branch::create_reference::Anchor::AtCommit { - commit_id: ws.tip_commit_by_segment_id(segment.id)?.id, + commit_id: segment.commits.first()?.id, position: Above, }) }) diff --git a/crates/but-api/src/legacy/virtual_branches.rs b/crates/but-api/src/legacy/virtual_branches.rs index 1f66816f52c..7ab8dc98960 100644 --- a/crates/but-api/src/legacy/virtual_branches.rs +++ b/crates/but-api/src/legacy/virtual_branches.rs @@ -11,10 +11,7 @@ use but_core::{ use but_ctx::Context; use but_error::bail_precondition; use but_oplog::legacy::{OperationKind, SnapshotDetails, Trailer}; -use but_rebase::graph_rebase::{ - Editor, - mutate::{InsertSide, RelativeToRef}, -}; +use but_rebase::graph_rebase::{Editor, mutate::InsertSide, selector::RelativeToRef}; use but_workspace::branch::unapply::WorkspaceDisposition; use but_workspace::legacy::ui::{StackEntryNoOpt, StackHeadInfo}; use gitbutler_branch::{BranchCreateRequest, BranchUpdateRequest}; @@ -652,8 +649,8 @@ fn commit_assigned_diffspec( } let mut meta = ctx.meta()?; - let (repo, mut ws, _) = ctx.workspace_mut_and_db_with_perm(perm)?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let (repo, ws, _) = ctx.workspace_mut_and_db_with_perm(perm)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let outcome = but_workspace::commit::commit_create( editor, assigned_diffspec, diff --git a/crates/but-api/src/legacy/workspace.rs b/crates/but-api/src/legacy/workspace.rs index ed1df2d2f5e..d62612482aa 100644 --- a/crates/but-api/src/legacy/workspace.rs +++ b/crates/but-api/src/legacy/workspace.rs @@ -6,10 +6,7 @@ use but_core::{RepositoryExt, ref_metadata::StackId}; use but_ctx::Context; use but_rebase::{ RebaseOutput, - graph_rebase::{ - Editor, LookupStep as _, - mutate::{InsertSide, RelativeToRef}, - }, + graph_rebase::{Editor, LookupStep as _, mutate::InsertSide, selector::RelativeToRef}, }; use but_workspace::{ commit_engine, @@ -43,7 +40,7 @@ pub fn head_info(ctx: &but_ctx::Context) -> Result { &meta, but_workspace::ref_info::Options { project_meta: ctx.project_meta()?, - traversal: but_graph::init::Options::limited(), + traversal: but_graph::walk::Options::limited(), expensive_commit_info: true, gerrit_mode, }, @@ -109,16 +106,16 @@ pub(crate) fn stacks_v3_from_ctx( pub fn show_graph_svg(ctx: &Context) -> Result<()> { let repo = ctx.open_isolated_repo()?; let meta = ctx.meta()?; - let graph = but_graph::Graph::from_head( + let graph = but_graph::Workspace::from_head( &repo, &meta, ctx.project_meta()?, - but_graph::init::Options { + but_graph::walk::Options { collect_tags: true, - ..but_graph::init::Options::limited() + ..but_graph::walk::Options::limited() }, )?; - graph.open_as_svg(); + graph.open_graph_as_svg(); Ok(()) } @@ -328,8 +325,8 @@ pub fn stash_into_branch( let outcome = { let mut meta = ctx.meta()?; - let (repo, mut ws, _) = ctx.workspace_mut_and_db_with_perm(perm)?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let (repo, ws, _) = ctx.workspace_mut_and_db_with_perm(perm)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let but_workspace::commit::CommitCreateOutcome { rebase, commit_selector, @@ -437,7 +434,7 @@ pub fn workspace_branch_and_ancestors_push( &meta, but_workspace::ref_info::Options { project_meta: ctx.project_meta()?, - traversal: but_graph::init::Options::limited(), + traversal: but_graph::walk::Options::limited(), expensive_commit_info: true, gerrit_mode, }, diff --git a/crates/but-api/src/resolve/apply.rs b/crates/but-api/src/resolve/apply.rs index 9b4e90ab709..b06254c94b7 100644 --- a/crates/but-api/src/resolve/apply.rs +++ b/crates/but-api/src/resolve/apply.rs @@ -213,7 +213,7 @@ pub(crate) fn apply( } let resolved_tree_id = tree_editor.write()?.detach(); - let mut editor = Editor::create(&mut ws, &mut meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let (target_selector, mut commit) = editor.find_selectable_commit(request.commit_id)?; commit.tree = resolved_tree_id; commit.message = but_core::commit::strip_conflict_markers(commit.message.as_ref()); @@ -229,7 +229,8 @@ pub(crate) fn apply( let rebase = editor.rebase()?; let new_commit = rebase.lookup_pick(target_selector)?; - let workspace = WorkspaceState::from_successful_rebase_with_db(rebase, &repo, dry_run, &db)?; + let workspace = + WorkspaceState::from_successful_rebase_with_db(&mut ws, rebase, &repo, dry_run, &db)?; Ok((new_commit, workspace)) } diff --git a/crates/but-api/src/workspace.rs b/crates/but-api/src/workspace.rs index df77c9fae8c..20872c9f7e4 100644 --- a/crates/but-api/src/workspace.rs +++ b/crates/but-api/src/workspace.rs @@ -15,7 +15,7 @@ use but_core::{ }; use but_forge::ForgeReview; use but_oplog::legacy::{OperationKind, SnapshotDetails}; -use but_rebase::graph_rebase::mutate::RelativeTo; +use but_rebase::graph_rebase::selector::RelativeTo; use but_serde::BStringForFrontend; use but_workspace::{ BottomUpdate, BottomUpdateKind, IntegrateUpstreamOutcome, ReviewIntegrationHint, @@ -161,9 +161,7 @@ pub fn get_workspace( ) -> anyhow::Result { let mut meta = ctx.meta()?; let (repo, workspace, _) = ctx.workspace_and_db_with_perm(perm)?; - let mut workspace = workspace.clone(); - but_workspace::workspace::detailed_graph_workspace(&mut workspace, &mut meta, &repo) - .map(Into::into) + but_workspace::workspace::detailed_graph_workspace(&workspace, &mut meta, &repo).map(Into::into) } /// Make `target_ref` the project's default target without applying branches or entering @@ -385,7 +383,7 @@ fn forge_review_integration_hints( db: &but_db::DbHandle, ) -> anyhow::Result> { let Some(target_branch_name) = - target_branch_name(&workspace.graph.symbolic_remote_names, project_meta) + target_branch_name(workspace.symbolic_remote_names(), project_meta) else { return Ok(vec![]); }; @@ -524,19 +522,23 @@ pub fn workspace_integrate_upstream_only_with_perm( ws_meta, project_meta, } = but_workspace::integrate_upstream_with_hints( - &mut ws, + &ws, &mut meta, project_meta, &repo, updates, &review_hints, )?; - let worktree_conflicts = but_workspace::worktree_conflicts_for_rebase(&rebase)?; + let worktree_conflicts = but_workspace::worktree_conflicts_for_rebase(&ws, &rebase)?; if dry_run.into() { let replaced_commits = rebase.history.commit_mappings(); - let workspace_state = - WorkspaceState::from_rebase_preview_with_db(&mut rebase, replaced_commits, &db)?; + let workspace_state = WorkspaceState::from_rebase_preview_with_db( + &ws, + &mut rebase, + replaced_commits, + &db, + )?; return Ok(WorkspaceIntegrateUpstreamOutcome { workspace_state, worktree_conflicts, @@ -545,8 +547,9 @@ pub fn workspace_integrate_upstream_only_with_perm( let materialized = rebase.materialize()?; project_meta.persist_to_local_config(&repo)?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; - if let Some(ref_name) = materialized.workspace.ref_name() + if let Some(ref_name) = ws.ref_name() && let Some(ws_meta) = ws_meta && is_workspace_ref_name(ref_name) { @@ -557,7 +560,7 @@ pub fn workspace_integrate_upstream_only_with_perm( } let workspace_state = WorkspaceState::from_workspace_with_db( - materialized.workspace, + &ws, materialized.meta, &repo, materialized.history.commit_mappings(), diff --git a/crates/but-api/src/workspace_state.rs b/crates/but-api/src/workspace_state.rs index 3d64be35783..5070b978408 100644 --- a/crates/but-api/src/workspace_state.rs +++ b/crates/but-api/src/workspace_state.rs @@ -2,7 +2,7 @@ use super::WorkspaceState; use std::collections::{BTreeMap, HashMap}; use but_core::{DryRun, RefMetadata}; -use but_rebase::graph_rebase::SuccessfulRebase; +use but_rebase::graph_rebase::{MaterializeOutcome, SuccessfulRebase}; /// Build a `{ pushed short name -> PR number }` lookup from the forge review /// cache, for resolving branch PR associations at projection time. @@ -110,8 +110,8 @@ impl WorkspaceState { workspace, repo, but_workspace::ref_info::Options { - project_meta: workspace.graph.project_meta.clone(), - traversal: but_graph::init::Options::limited(), + project_meta: workspace.project_meta().clone(), + traversal: but_graph::walk::Options::limited(), expensive_commit_info: true, ..Default::default() }, @@ -132,9 +132,8 @@ impl WorkspaceState { // The graph_workspace projection needs its own equivalent enrichment; // that is out of scope here. let _ = prs_by_head; - let mut workspace = workspace.clone(); let graph_workspace = - but_workspace::workspace::detailed_graph_workspace(&mut workspace, meta, repo)?; + but_workspace::workspace::detailed_graph_workspace(workspace, meta, repo)?; Ok(WorkspaceState { replaced_commits, @@ -183,13 +182,15 @@ impl WorkspaceState { /// The `replaced_commits` map should describe the commit rewrites visible in the /// preview graph, which typically comes from `rebase.history.commit_mappings()`. fn from_rebase_preview( - rebase: &mut SuccessfulRebase<'_, '_, M>, + workspace: &but_graph::Workspace, + rebase: &mut SuccessfulRebase<'_, M>, replaced_commits: BTreeMap, prs_by_head: &HashMap, ) -> anyhow::Result { - let workspace = rebase.overlayed_graph()?.into_workspace()?; + let overlay = rebase.rebase_overlay()?; let (repo, meta) = rebase.repo_and_meta_mut(); - Self::from_workspace(&workspace, meta, repo, replaced_commits, prs_by_head) + let preview = workspace.redo(repo, meta, overlay)?; + Self::from_workspace(&preview, meta, repo, replaced_commits, prs_by_head) } /// Build a preview [`WorkspaceState`] from a successful rebase without materializing it. @@ -198,12 +199,47 @@ impl WorkspaceState { /// the workspace cache DB. It derives PR associations from the forge review /// cache before projecting the preview state. pub(crate) fn from_rebase_preview_with_db( - rebase: &mut SuccessfulRebase<'_, '_, M>, + workspace: &but_graph::Workspace, + rebase: &mut SuccessfulRebase<'_, M>, replaced_commits: BTreeMap, db: &but_db::DbHandle, ) -> anyhow::Result { let prs_by_head = forge_prs_by_head(db)?; - Self::from_rebase_preview(rebase, replaced_commits, &prs_by_head) + Self::from_rebase_preview(workspace, rebase, replaced_commits, &prs_by_head) + } + + /// Build a [`WorkspaceState`] from an already-materialized rebase, refreshing `workspace` + /// from the rebase's mutated commit graph along the way. + /// + /// Use this when the caller needs to perform additional bookkeeping after materialization + /// before constructing the final workspace state. Carries no PR associations โ€” the + /// transaction layer this serves intentionally does not depend on forge storage. + pub fn from_materialized_rebase( + workspace: &mut but_graph::Workspace, + materialized: MaterializeOutcome<'_, M>, + repo: &gix::Repository, + ) -> anyhow::Result { + Self::from_materialized_rebase_inner(workspace, materialized, repo, &HashMap::new()) + } + + fn from_materialized_rebase_inner( + workspace: &mut but_graph::Workspace, + materialized: MaterializeOutcome<'_, M>, + repo: &gix::Repository, + prs_by_head: &HashMap, + ) -> anyhow::Result { + workspace.refresh_from_commit_graph( + materialized.arena().clone(), + repo, + materialized.meta, + )?; + Self::from_workspace( + workspace, + materialized.meta, + repo, + materialized.history.commit_mappings(), + prs_by_head, + ) } /// Build a [`WorkspaceState`] from a successful rebase, materializing it when needed. @@ -215,7 +251,8 @@ impl WorkspaceState { /// workspace state together with the final commit-replacement mappings returned by the /// materialized history. fn from_successful_rebase( - rebase: SuccessfulRebase<'_, '_, M>, + workspace: &mut but_graph::Workspace, + rebase: SuccessfulRebase<'_, M>, repo: &gix::Repository, dry_run: DryRun, prs_by_head: &HashMap, @@ -223,17 +260,16 @@ impl WorkspaceState { if dry_run.into() { let mut rebase = rebase; let replaced_commits = rebase.history.commit_mappings(); - return Self::from_rebase_preview(&mut rebase, replaced_commits, prs_by_head); + return Self::from_rebase_preview( + workspace, + &mut rebase, + replaced_commits, + prs_by_head, + ); } let materialized = rebase.materialize()?; - Self::from_workspace( - materialized.workspace, - materialized.meta, - repo, - materialized.history.commit_mappings(), - prs_by_head, - ) + Self::from_materialized_rebase_inner(workspace, materialized, repo, prs_by_head) } /// Build a [`WorkspaceState`] from a successful rebase without PR associations. @@ -243,11 +279,12 @@ impl WorkspaceState { /// for layers that intentionally do not depend on forge storage and whose /// consumers do not observe PR association fields. pub fn from_successful_rebase_without_pr_associations( - rebase: SuccessfulRebase<'_, '_, M>, + workspace: &mut but_graph::Workspace, + rebase: SuccessfulRebase<'_, M>, repo: &gix::Repository, dry_run: DryRun, ) -> anyhow::Result { - Self::from_successful_rebase(rebase, repo, dry_run, &HashMap::new()) + Self::from_successful_rebase(workspace, rebase, repo, dry_run, &HashMap::new()) } /// Build a [`WorkspaceState`] from a successful rebase, materializing it when needed. @@ -256,12 +293,13 @@ impl WorkspaceState { /// the workspace cache DB. It derives PR associations from the forge review /// cache before projecting the final or dry-run workspace state. pub(crate) fn from_successful_rebase_with_db( - rebase: SuccessfulRebase<'_, '_, M>, + workspace: &mut but_graph::Workspace, + rebase: SuccessfulRebase<'_, M>, repo: &gix::Repository, dry_run: DryRun, db: &but_db::DbHandle, ) -> anyhow::Result { let prs_by_head = forge_prs_by_head(db)?; - Self::from_successful_rebase(rebase, repo, dry_run, &prs_by_head) + Self::from_successful_rebase(workspace, rebase, repo, dry_run, &prs_by_head) } } diff --git a/crates/but-api/tests/api/branch_checkout.rs b/crates/but-api/tests/api/branch_checkout.rs index 2e060716ace..c3233f23f73 100644 --- a/crates/but-api/tests/api/branch_checkout.rs +++ b/crates/but-api/tests/api/branch_checkout.rs @@ -194,14 +194,6 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(5374caf21933aee76b72bad8d6e30949c7a30e04), - }, - ), - lower_bound: Some( - NodeIndex(1), - ), is_managed_ref: false, is_managed_commit: false, ancestor_workspace_commit: None, @@ -251,7 +243,7 @@ fn checkout_new_returns_head_info_matching_fresh_head_info() -> anyhow::Result<( crate::support::workspace_graph(&ctx)?, snapbox::str![[r#" โŒ‚:new-branch[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 5374caf -โ””โ”€โ”€ โ‰ก:new-branch[๐ŸŒณ] {1} +โ””โ”€โ”€ โ‰ก:new-branch[๐ŸŒณ] on 5374caf {1} โ””โ”€โ”€ :new-branch[๐ŸŒณ] "#]] @@ -304,7 +296,9 @@ RefInfo { id: Some( 00000000-0000-0000-0000-000000000001, ), - base: None, + base: Some( + Sha1(5374caf21933aee76b72bad8d6e30949c7a30e04), + ), segments: [ ref_info::ui::Segment { ref_name: "โ–บnew-branch[๐ŸŒณ]", @@ -314,7 +308,7 @@ RefInfo { commits_outside: None, metadata: "None", push_status: CompletelyUnpushed, - base: "None", + base: "5374caf", }, ], }, @@ -327,14 +321,6 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(5374caf21933aee76b72bad8d6e30949c7a30e04), - }, - ), - lower_bound: Some( - NodeIndex(0), - ), is_managed_ref: false, is_managed_commit: false, ancestor_workspace_commit: None, diff --git a/crates/but-api/tests/api/support.rs b/crates/but-api/tests/api/support.rs index b5a96032baa..2382cc1b2c2 100644 --- a/crates/but-api/tests/api/support.rs +++ b/crates/but-api/tests/api/support.rs @@ -154,7 +154,7 @@ pub fn fresh_head_info(ctx: &but_ctx::Context) -> anyhow::Result anyhow::Result { let mut meta = ctx.meta()?; let (_guard, repo, ws, _db) = ctx.workspace_and_db()?; - let mut ws = ws.clone(); - but_workspace::workspace::detailed_graph_workspace(&mut ws, &mut meta, &repo).map(Into::into) + but_workspace::workspace::detailed_graph_workspace(&ws, &mut meta, &repo).map(Into::into) } diff --git a/crates/but-core/src/ref_metadata.rs b/crates/but-core/src/ref_metadata.rs index 4ee4f9ae6b4..d30e3f147f2 100644 --- a/crates/but-core/src/ref_metadata.rs +++ b/crates/but-core/src/ref_metadata.rs @@ -743,7 +743,7 @@ impl Workspace { /// Return `true` if the branch with `name` is the workspace target or the targets local tracking branch, /// using `repo` for the lookup of the local tracking branch. - pub fn is_branch_the_target_or_its_local_tracking_branch( + pub fn is_target_or_its_local_tracking( &self, name: &gix::refs::FullNameRef, repo: &gix::Repository, diff --git a/crates/but-ctx/src/lib.rs b/crates/but-ctx/src/lib.rs index bf3bd98ecb5..c8b6e4c3cf3 100644 --- a/crates/but-ctx/src/lib.rs +++ b/crates/but-ctx/src/lib.rs @@ -771,13 +771,12 @@ impl Context { self.project_data_dir().join("virtual_branches.toml"), self.project_data_dir(), )?; - let graph = but_graph::Graph::from_head( + but_graph::Workspace::from_head( &repo, &meta, self.project_meta()?, - but_graph::init::Options::limited(), - )?; - graph.into_workspace() + but_graph::walk::Options::limited(), + ) } fn meta_inner_read_only(&self) -> anyhow::Result { diff --git a/crates/but-debug/src/args.rs b/crates/but-debug/src/args.rs index aafd3e771b5..e84f241abb7 100644 --- a/crates/but-debug/src/args.rs +++ b/crates/but-debug/src/args.rs @@ -148,9 +148,6 @@ pub struct GraphArgs { /// The rev-spec of the extra target to provide for traversal. #[arg(long)] pub extra_target: Option, - /// Disable post-processing of the graph, useful if that's failing. - #[arg(long)] - pub no_post: bool, /// Do not debug-print the workspace. /// /// If too large, it takes a long time or runs out of memory. diff --git a/crates/but-debug/src/command/dump/diagnostics.rs b/crates/but-debug/src/command/dump/diagnostics.rs index 59d8fa6d310..e7ef885453b 100644 --- a/crates/but-debug/src/command/dump/diagnostics.rs +++ b/crates/but-debug/src/command/dump/diagnostics.rs @@ -87,7 +87,7 @@ impl Diagnostics { err: &mut dyn io::Write, ) -> Result { let (_guard, _repo, ws, _db) = ctx.workspace_and_db()?; - let dot_graph = ws.graph.dot_graph_pruned(); + let dot_graph = ws.dot_graph_pruned(); let workspace_debug = format!("{ws:#?}\n"); let mut files = vec![ diff --git a/crates/but-debug/src/command/graph.rs b/crates/but-debug/src/command/graph.rs index 28c6ae9c910..f7256119950 100644 --- a/crates/but-debug/src/command/graph.rs +++ b/crates/but-debug/src/command/graph.rs @@ -50,7 +50,7 @@ pub(crate) fn run( .map(|rev_spec| repo.rev_parse_single(rev_spec)) .transpose()? .map(|id| id.detach()); - let opts = but_graph::init::Options { + let opts = but_graph::walk::Options { extra_target_commit_id: extra_target, collect_tags: true, hard_limit: graph_args.hard_limit, @@ -69,11 +69,10 @@ pub(crate) fn run( .expect("the prefix is unambiguous") }) .collect(), - dangerously_skip_postprocessing_for_debugging: graph_args.no_post, }; - let graph = match graph_args.ref_name.as_deref() { - None => but_graph::Graph::from_head( + let workspace = match graph_args.ref_name.as_deref() { + None => but_graph::Workspace::from_head( &repo, &meta, but_core::ref_metadata::ProjectMeta::default(), @@ -82,7 +81,7 @@ pub(crate) fn run( Some(ref_name) => { let mut reference = repo.find_reference(ref_name)?; let id = reference.peel_to_id()?; - but_graph::Graph::from_commit_traversal( + but_graph::Workspace::from_tip( id, reference.name().to_owned(), &meta, @@ -92,7 +91,6 @@ pub(crate) fn run( } }?; - let workspace = graph.into_workspace()?; emit_workspace(&workspace, graph_args, out, err) } @@ -102,12 +100,12 @@ fn emit_workspace( out: &mut dyn io::Write, err: &mut dyn io::Write, ) -> Result<()> { - let errors = workspace.graph.validation_errors(); + let errors = workspace.validation_errors(); if !errors.is_empty() { writeln!(err, "VALIDATION FAILED: {errors:?}")?; } if graph_args.stats { - writeln!(err, "{:#?}", workspace.graph.statistics())?; + writeln!(err, "{:#?}", workspace.statistics())?; } if graph_args.no_debug_workspace { @@ -132,14 +130,14 @@ fn emit_workspace( match dot_mode(graph_args) { Some(DotMode::Print) => { - out.write_all(workspace.graph.dot_graph_pruned().as_bytes())?; + out.write_all(workspace.dot_graph_pruned().as_bytes())?; } Some(DotMode::OpenAsSvg) => { #[cfg(unix)] - workspace.graph.open_as_svg(); + workspace.open_graph_as_svg(); } Some(DotMode::Debug) => { - writeln!(err, "{graph:#?}", graph = workspace.graph)?; + writeln!(err, "{}", workspace.graph_debug_string())?; } None => {} } @@ -156,7 +154,6 @@ fn emit_workspace( /// must be passed directly into `but_graph::Graph::from_*`. fn uses_context_discovery(graph_args: &GraphArgs) -> bool { graph_args.extra_target.is_none() - && !graph_args.no_post && graph_args.hard_limit.is_none() && graph_args.limit == Some(Some(300)) && graph_args.limit_extension.is_empty() diff --git a/crates/but-debug/src/command/revision.rs b/crates/but-debug/src/command/revision.rs index d8b65973110..10afa91cce4 100644 --- a/crates/but-debug/src/command/revision.rs +++ b/crates/but-debug/src/command/revision.rs @@ -4,7 +4,7 @@ use std::io::{self, Write as _}; use anyhow::{Context as _, Result, bail, ensure}; use but_graph::FirstParent; -use but_graph::init::Tip; +use but_graph::walk::Seed; use gix::{odb::store::RefreshMode, reference::Category, revision::plumbing::Spec}; use crate::{ @@ -59,11 +59,7 @@ fn log( let _span = tracing::info_span!("traverse graph").entered(); let commits = if let Some(excluded) = excluded { - graph.find_commit_ids_reachable_from_a_not_b( - included, - excluded, - FirstParent::from(log_args.first_parent), - )? + graph.commit_ids_in_a_not_b(included, excluded, FirstParent::from(log_args.first_parent))? } else { bail!("Need to specify a rev-spec of form `a..b` to indicate an exclusion for now.") }; @@ -105,34 +101,12 @@ fn merge_base( graph_for_revisions(repo, meta, &commits, graph_tips)? }; - let segments = { - let _span = tracing::info_span!("map commit ids to segments", commit_count = commits.len()) - .entered(); - commits - .iter() - .copied() - .map(|commit_id| graph.segment_id_by_commit_id(commit_id)) - .collect::>>() - .context("Failed to map commit ids to graph segments")? - }; - let merge_base = { let _span = tracing::info_span!("compute octopus merge-base", commit_count = commits.len()) .entered(); graph - .find_merge_base_octopus(segments) - .map(|segment_id| { - graph - .tip_skip_empty(segment_id) - .map(|commit| commit.id) - .with_context(|| { - format!( - "BUG: Segment {segment_id:?} does not contain a reachable tip commit" - ) - }) - }) - .transpose() - .context("Failed to compute octopus merge-base from graph")? + .commit_graph() + .merge_base_octopus(commits.iter().copied()) }; let Some(merge_base) = merge_base else { @@ -146,7 +120,7 @@ fn merge_base( Ok(()) } -fn args_to_tips(repo: &gix::Repository, graph_args: &RevisionGraphArgs) -> Result> { +fn args_to_tips(repo: &gix::Repository, graph_args: &RevisionGraphArgs) -> Result> { let mut tips = Vec::new(); if let Some(tip) = graph_args @@ -162,7 +136,7 @@ fn args_to_tips(repo: &gix::Repository, graph_args: &RevisionGraphArgs) -> Resul "Target ref '{name}' resolved from '{target_ref}' is not a remote-tracking branch; use --extra-target for arbitrary revisions" ); let id = reference.peel_to_id()?.detach(); - Ok(Tip::integrated(id, Some(name))) + Ok(Seed::integrated(id, Some(name))) }) .transpose()? { @@ -174,7 +148,7 @@ fn args_to_tips(repo: &gix::Repository, graph_args: &RevisionGraphArgs) -> Resul .as_deref() .map(|rev| { repo.rev_parse_single(rev) - .map(|id| Tip::integrated(id.detach(), None)) + .map(|id| Seed::integrated(id.detach(), None)) .with_context(|| format!("Failed to resolve extra target '{rev}'")) }) .transpose()? @@ -189,27 +163,27 @@ fn graph_for_revisions( repo: &gix::Repository, meta: &EmptyRefMetadata, commits: &[gix::ObjectId], - graph_tips: Vec, -) -> Result { + graph_tips: Vec, +) -> Result { let first = *commits .first() .context("BUG: revision graph requires at least one commit")?; - let options = but_graph::init::Options { + let options = but_graph::walk::Options { collect_tags: false, commits_limit_hint: None, ..Default::default() }; - let tips = std::iter::once(Tip::entrypoint(first, None)) + let tips = std::iter::once(Seed::entrypoint(first, None)) .chain( commits .iter() .copied() .skip(1) - .map(|id| Tip::reachable(id, None)), + .map(|id| Seed::reachable(id, None)), ) .chain(graph_tips); - but_graph::Graph::from_commit_traversal_tips( + but_graph::Workspace::from_seeds( repo, tips, meta, diff --git a/crates/but-feedback/src/lib.rs b/crates/but-feedback/src/lib.rs index 86c03d873dd..e24b9e9496b 100644 --- a/crates/but-feedback/src/lib.rs +++ b/crates/but-feedback/src/lib.rs @@ -39,21 +39,9 @@ impl Archival { meta: &impl RefMetadata, ) -> Result { let project_meta = but_core::ref_metadata::ProjectMeta::resolve(repo, meta)?; - let mut graph = - but_graph::Graph::from_head(repo, meta, project_meta.clone(), Default::default()) - .or_else(|_| { - but_graph::Graph::from_head( - repo, - meta, - project_meta, - but_graph::init::Options { - // Assume it fails because of post-processing, try again without. - dangerously_skip_postprocessing_for_debugging: true, - ..Default::default() - }, - ) - })?; - let dot_file_contents = graph.anonymize(&repo.remote_names())?.dot_graph_pruned(); + let ws = but_graph::Workspace::from_head(repo, meta, project_meta, Default::default())? + .anonymized(&repo.remote_names())?; + let dot_file_contents = ws.dot_graph_pruned(); let output_file = self.cache_dir.join(format!( "commit-graph-anon-{date}.zip", date = filesafe_date_time() diff --git a/crates/but-graph/Cargo.toml b/crates/but-graph/Cargo.toml index 7471576e59f..b1c8187baca 100644 --- a/crates/but-graph/Cargo.toml +++ b/crates/but-graph/Cargo.toml @@ -14,13 +14,11 @@ doctest = false ## Gate functions we only need to help `gitbutler-` crates along, ## slated for culling or review before promotion to non-legacy legacy = [] - [dependencies] but-core.workspace = true gix = { workspace = true, features = ["revision"] } bstr.workspace = true -petgraph.workspace = true anyhow.workspace = true bitflags.workspace = true boolean-enums.workspace = true @@ -28,9 +26,9 @@ tracing.workspace = true itertools.workspace = true [dev-dependencies] -but-graph = { path = ".", features = ["legacy"] } but-meta = { workspace = true, features = ["legacy"] } but-testsupport.workspace = true +termtree = "0.5.1" gitbutler-reference.workspace = true snapbox.workspace = true diff --git a/crates/but-graph/src/debug.rs b/crates/but-graph/src/debug.rs index 7806da6693f..0cc76e6106f 100644 --- a/crates/but-graph/src/debug.rs +++ b/crates/but-graph/src/debug.rs @@ -1,22 +1,112 @@ -use std::collections::{BTreeMap, VecDeque}; +use std::collections::BTreeMap; use anyhow::{Context as _, bail}; use bstr::{BString, ByteSlice, ByteVec}; use gix::reference::Category; -use petgraph::{prelude::EdgeRef, stable_graph::EdgeReference}; -use crate::{ - CommitFlags, Edge, Graph, Segment, SegmentIndex, SegmentMetadata, StopCondition, init::PetGraph, -}; +use crate::StopCondition; /// Debugging -impl Graph { - /// Assure that no PII is left in the graph, by deterministically anonymizing branch names. +impl crate::Workspace { + /// Assure that no PII is left, by deterministically anonymizing branch names everywhere + /// the workspace surfaces them: the projected stacks, the carried commit graph's refs, + /// the target, the captured tip/entrypoint facts, and workspace metadata. Commit + /// information (flags, hashes) is kept. Consuming on purpose: the anonymized workspace + /// is a different artifact for display, and the falsified original must not linger. /// - /// What it will keep is `gitbutler/` references, and all commit information (flags, hashes). - /// - /// Use `remotes` to know how to separate the remote name from the branch name of a short name. - pub fn anonymize(&mut self, remotes: &gix::remote::Names) -> anyhow::Result<&mut Self> { + /// Use `remotes` to know how to separate the remote name from the branch name of a + /// short name. + pub fn anonymized(mut self, remotes: &gix::remote::Names) -> anyhow::Result { + let mut anon = RefAnonymizer::new(remotes); + for stack in &mut self.stacks { + for segment in &mut stack.segments { + if let Some(ri) = segment.ref_info.as_mut() { + anon.apply(&mut ri.ref_name)?; + } + if let Some(rn) = segment.remote_tracking_ref_name.as_mut() { + anon.apply(rn)?; + } + for ri in segment + .commits + .iter_mut() + .flat_map(|c| c.refs.iter_mut()) + .chain( + segment + .commits_on_remote + .iter_mut() + .flat_map(|c| c.refs.iter_mut()), + ) + { + anon.apply(&mut ri.ref_name)?; + } + } + } + match &mut self.kind { + crate::workspace::WorkspaceKind::Managed { ref_info } + | crate::workspace::WorkspaceKind::ManagedMissingWorkspaceCommit { ref_info } => { + anon.apply(&mut ref_info.ref_name)?; + } + crate::workspace::WorkspaceKind::AdHoc => {} + } + if let Some(t) = self.target_ref.as_mut() { + anon.apply(&mut t.ref_name)?; + } + if let Some(ri) = self.tip_ref_info.as_mut() { + anon.apply(&mut ri.ref_name)?; + } + if let Some(rn) = self.lower_bound_ref_name.as_mut() { + anon.apply(rn)?; + } + if let Some(ep) = self.entrypoint.as_mut() + && let Some(rn) = ep.ref_name.as_mut() + { + anon.apply(rn)?; + } + if let Some(md) = self.metadata.as_mut() { + for rn in md + .stacks + .iter_mut() + .flat_map(|s| s.branches.iter_mut().map(|b| &mut b.ref_name)) + { + anon.apply(rn)?; + } + } + self.ctx.remote_tracking = std::mem::take(&mut self.ctx.remote_tracking) + .into_iter() + .map(|(mut local, mut remote)| { + anon.apply(&mut local)?; + anon.apply(&mut remote)?; + Ok((local, remote)) + }) + .collect::>()?; + self.commit_graph.anonymize_refs(&mut anon)?; + Ok(self) + } +} + +/// Deterministic branch-name anonymization: local branches and tags become alphabetic +/// aliases, remote-tracking branches keep their remote/name split with both parts +/// aliased. `gitbutler/` references are kept. The same input always maps to the same +/// alias within one anonymizer. +pub(crate) struct RefAnonymizer { + remotes: Vec, + remote_mapping: BTreeMap, + name_mapping: BTreeMap, +} + +impl RefAnonymizer { + pub(crate) fn new(remotes: &gix::remote::Names) -> Self { + RefAnonymizer { + remotes: remotes + .iter() + .map(|r| r.as_ref().as_bstr().to_owned()) + .collect(), + remote_mapping: BTreeMap::new(), + name_mapping: BTreeMap::new(), + } + } + + pub(crate) fn apply(&mut self, rn: &mut gix::refs::FullName) -> anyhow::Result<()> { fn int_to_alpha(mut n: usize) -> String { let mut result = String::new(); while n > 0 { @@ -32,327 +122,239 @@ impl Graph { result } - let mut remote_mapping = BTreeMap::::new(); - let mut name_mapping = BTreeMap::::new(); - let mut anon = |rn: &mut gix::refs::FullName| -> anyhow::Result<()> { - let (category, short_name) = rn - .category_and_short_name() - .with_context(|| format!("Couldn't classify reference '{rn}'"))?; - match category { - Category::Tag | Category::LocalBranch => { - let num_names = name_mapping.len(); - let new_name = name_mapping - .entry(short_name.to_owned()) - .or_insert_with(|| int_to_alpha(num_names).into()); - *rn = category.to_full_name(new_name.as_bstr())?; - } - Category::RemoteBranch => { - let (remote_name, short_name) = remotes - .iter() - .rev() - .find_map(|remote| { - rn.as_bstr()[Category::RemoteBranch.prefix().len()..] - .as_bstr() - .strip_prefix(remote.as_bytes()) - .map(|short_name| (remote, short_name.as_bstr())) - }) - .with_context(|| format!("Couldn't determine remote name in {rn}"))?; - - let short_name = short_name - .strip_prefix(b"/") - .with_context(|| { - format!("Couldn't *unambiguously* determine remote name in {rn}") - })? - .as_bstr(); + let (category, short_name) = rn + .category_and_short_name() + .with_context(|| format!("Couldn't classify reference '{rn}'"))?; + match category { + Category::Tag | Category::LocalBranch => { + // 1-indexed: int_to_alpha(0) and (1) both yield "A". + let num_names = self.name_mapping.len() + 1; + let new_name = self + .name_mapping + .entry(short_name.to_owned()) + .or_insert_with(|| int_to_alpha(num_names).into()); + *rn = category.to_full_name(new_name.as_bstr())?; + } + Category::RemoteBranch => { + let (remote_name, short_name) = self + .remotes + .iter() + .rev() + .find_map(|remote| { + rn.as_bstr()[Category::RemoteBranch.prefix().len()..] + .as_bstr() + .strip_prefix(remote.as_bytes()) + .map(|short_name| (remote, short_name.as_bstr())) + }) + .with_context(|| format!("Couldn't determine remote name in {rn}"))?; - let mut new_name: BString = "refs/remotes/".into(); + let short_name = short_name + .strip_prefix(b"/") + .with_context(|| { + format!("Couldn't *unambiguously* determine remote name in {rn}") + })? + .as_bstr(); - let num_remotes = remote_mapping.len(); - let new_remote_name = remote_mapping - .entry(remote_name.as_bstr().to_owned()) - .or_insert_with(|| format!("remote-{num_remotes}").into()); - new_name.push_str(new_remote_name); + let mut new_name: BString = "refs/remotes/".into(); - let num_names = name_mapping.len(); - let new_short_name = name_mapping - .entry(short_name.to_owned()) - .or_insert_with(|| int_to_alpha(num_names).into()); - new_name.push_byte(b'/'); - new_name.push_str(new_short_name); - *rn = gix::refs::FullName::try_from(new_name.as_bstr()) - .expect("Our replacement names are always valid"); - } + let num_remotes = self.remote_mapping.len(); + let new_remote_name = self + .remote_mapping + .entry(remote_name.as_bstr().to_owned()) + .or_insert_with(|| format!("remote-{num_remotes}").into()); + new_name.push_str(new_remote_name); - Category::Note - | Category::PseudoRef - | Category::MainPseudoRef - | Category::MainRef - | Category::LinkedPseudoRef { .. } - | Category::LinkedRef { .. } - | Category::Bisect - | Category::Rewritten - | Category::WorktreePrivate => { - bail!("Can't handle reference '{rn}' of category '{category:?}'"); - } + let num_names = self.name_mapping.len() + 1; + let new_short_name = self + .name_mapping + .entry(short_name.to_owned()) + .or_insert_with(|| int_to_alpha(num_names).into()); + new_name.push_byte(b'/'); + new_name.push_str(new_short_name); + *rn = gix::refs::FullName::try_from(new_name.as_bstr()) + .expect("Our replacement names are always valid"); } - Ok(()) - }; - for node in self.inner.node_weights_mut() { - if let Some(ri) = node.ref_info.as_mut() { - anon(&mut ri.ref_name)?; - } - if let Some(rn) = node.remote_tracking_ref_name.as_mut() { - anon(rn)?; - } - for ri in node.commits.iter_mut().flat_map(|c| c.refs.iter_mut()) { - anon(&mut ri.ref_name)?; - } - if let Some(SegmentMetadata::Workspace(md)) = node.metadata.as_mut() { - for rn in md - .stacks - .iter_mut() - .flat_map(|s| s.branches.iter_mut().map(|b| &mut b.ref_name)) - { - anon(rn)?; - } + + Category::Note + | Category::PseudoRef + | Category::MainPseudoRef + | Category::MainRef + | Category::LinkedPseudoRef { .. } + | Category::LinkedRef { .. } + | Category::Bisect + | Category::Rewritten + | Category::WorktreePrivate => { + bail!("Can't handle reference '{rn}' of category '{category:?}'"); } } - Ok(self) - } - /// Produce a string that concisely represents `commit`, adding `extra` information as needed. - pub fn commit_debug_string( - commit: &crate::Commit, - is_entrypoint: bool, - stop_condition: Option, - hard_limit: bool, - max_goals: Option, - ) -> String { - Self::commit_debug_string_inner( - commit, - is_entrypoint, - stop_condition, - hard_limit, - max_goals, - false, - ) - } - - /// Like [`Self::commit_debug_string()`], but includes graph-contextual worktree ownership markers. - pub fn commit_debug_string_with_graph_context( - &self, - commit: &crate::Commit, - is_entrypoint: bool, - stop_condition: Option, - hard_limit: bool, - max_goals: Option, - ) -> String { - Self::commit_debug_string_inner( - commit, - is_entrypoint, - stop_condition, - hard_limit, - max_goals, - self.has_multiple_worktrees(), - ) + Ok(()) } +} - fn commit_debug_string_inner( - commit: &crate::Commit, - is_entrypoint: bool, - stop_condition: Option, - hard_limit: bool, - max_goals: Option, - show_owned_by_repo: bool, - ) -> String { - format!( - "{ep}{end}{kind}{hex}{flags}{refs}", - ep = if is_entrypoint { "๐Ÿ‘‰" } else { "" }, - end = stop_condition - .map(|condition| condition.debug_string(hard_limit)) - .unwrap_or_default(), - kind = if commit.flags.is_remote() { - "๐ŸŸฃ" - } else { - "ยท" - }, - flags = if !commit.flags.is_empty() { - format!(" ({})", commit.flags.debug_string(max_goals)) - } else { - "".to_string() - }, - hex = commit.id.to_hex_with_len(7), - refs = if commit.refs.is_empty() { - "".to_string() - } else { - format!( - " {}", - commit - .refs - .iter() - .map(|ri| format!("โ–บ{}", { - Self::ref_debug_string_inner( - ri.ref_name.as_ref(), - ri.worktree.as_ref(), - show_owned_by_repo, - ) - })) - .collect::>() - .join(", ") - ) +impl crate::CommitGraph { + /// Apply `anon` to every reference attached to commits, plus the entrypoint ref, + /// rebuilding the ref lookup afterwards. The layout is NOT rewritten โ€” an anonymized + /// graph is a display artifact, not an editing substrate. + pub(crate) fn anonymize_refs(&mut self, anon: &mut RefAnonymizer) -> anyhow::Result<()> { + let ids: Vec<_> = self.commit_ids().collect(); + for id in ids { + let Some(refs) = self.commit_refs_mut(id) else { + continue; + }; + for ri in refs { + anon.apply(&mut ri.ref_name)?; } - ) - } - - /// Shorten the given `name` so it's still clear if it is a special ref (like tag) or not. - pub fn ref_debug_string( - ref_name: &gix::refs::FullNameRef, - worktree: Option<&crate::Worktree>, - ) -> String { - Self::ref_debug_string_inner(ref_name, worktree, false) - } - - /// Like [`Self::ref_debug_string()`], but includes graph-contextual worktree ownership markers. - pub fn ref_debug_string_with_graph_context( - &self, - ref_name: &gix::refs::FullNameRef, - worktree: Option<&crate::Worktree>, - ) -> String { - Self::ref_debug_string_inner(ref_name, worktree, self.has_multiple_worktrees()) - } - - fn ref_debug_string_inner( - ref_name: &gix::refs::FullNameRef, - worktree: Option<&crate::Worktree>, - show_owned_by_repo: bool, - ) -> String { - let (cat, sn) = ref_name.category_and_short_name().expect("valid refs"); - // Only shorten those that look good and are unambiguous enough. - format!( - "{}{ws}", - if matches!(cat, Category::LocalBranch | Category::RemoteBranch) { - sn - } else { - ref_name - .as_bstr() - .strip_prefix(b"refs/") - .map(|n| n.as_bstr()) - .unwrap_or(ref_name.as_bstr()) - }, - ws = worktree - .map(|wt| wt.debug_string_with_graph_context(ref_name, show_owned_by_repo)) - .unwrap_or_default() - ) + } + if let Some(rn) = self.entrypoint_ref_mut() { + anon.apply(rn)?; + } + self.rebuild_by_ref(); + Ok(()) } +} - /// Return a useful one-line string showing the relationship between `ref_name`, `remote_ref_name` and how - /// they are linked with `sibling_id` and `remote_tracking_branch_id`. - pub fn ref_and_remote_debug_string( - ref_info: Option<&crate::RefInfo>, - remote_ref_name: Option<&gix::refs::FullName>, - sibling_id: Option, - remote_tracking_branch_id: Option, - ) -> String { - Self::ref_and_remote_debug_string_inner( - ref_info, - remote_ref_name, - sibling_id, - remote_tracking_branch_id, - false, - ) - } +/// Debugging +pub(crate) fn commit_debug_string_inner( + commit: &crate::Commit, + is_entrypoint: bool, + stop_condition: Option, + hard_limit: bool, + show_owned_by_repo: bool, +) -> String { + format!( + "{ep}{end}{kind}{hex}{flags}{refs}", + ep = if is_entrypoint { "๐Ÿ‘‰" } else { "" }, + end = stop_condition + .map(|condition| condition.debug_string(hard_limit)) + .unwrap_or_default(), + kind = if commit.flags.is_remote() { + "๐ŸŸฃ" + } else { + "ยท" + }, + flags = if !commit.flags.is_empty() { + format!(" ({})", commit.flags.debug_string()) + } else { + "".to_string() + }, + hex = commit.id.to_hex_with_len(7), + refs = if commit.refs.is_empty() { + "".to_string() + } else { + format!( + " {}", + commit + .refs + .iter() + .map(|ri| format!("โ–บ{}", { + ref_debug_string_inner( + ri.ref_name.as_ref(), + ri.worktree.as_ref(), + show_owned_by_repo, + ) + })) + .collect::>() + .join(", ") + ) + } + ) +} - /// Like [`Self::ref_and_remote_debug_string()`], but includes graph-contextual worktree ownership markers. - pub fn ref_and_remote_debug_string_with_graph_context( - &self, - ref_info: Option<&crate::RefInfo>, - remote_ref_name: Option<&gix::refs::FullName>, - sibling_id: Option, - remote_tracking_branch_id: Option, - ) -> String { - Self::ref_and_remote_debug_string_inner( - ref_info, - remote_ref_name, - sibling_id, - remote_tracking_branch_id, - self.has_multiple_worktrees(), - ) - } +/// Shorten the given `name` so it's still clear if it is a special ref (like tag) or not. +pub(crate) fn ref_debug_string( + ref_name: &gix::refs::FullNameRef, + worktree: Option<&crate::Worktree>, +) -> String { + ref_debug_string_inner(ref_name, worktree, false) +} - fn ref_and_remote_debug_string_inner( - ref_info: Option<&crate::RefInfo>, - remote_ref_name: Option<&gix::refs::FullName>, - sibling_id: Option, - remote_tracking_branch_id: Option, - show_owned_by_repo: bool, - ) -> String { - format!( - "{ref_name}{remote}", - ref_name = ref_info - .as_ref() - .map(|ri| format!( - "{}{maybe_id}", - Graph::ref_debug_string_inner( - ri.ref_name.as_ref(), - ri.worktree.as_ref(), - show_owned_by_repo, - ), - maybe_id = sibling_id - .filter(|_| remote_ref_name.is_none()) - .map(|id| format!(" โ†’:{}:", id.index())) - .unwrap_or_default() - )) - .unwrap_or_else(|| format!( - "anon:{maybe_id}", - maybe_id = sibling_id - .map(|id| format!(" โ†’:{}:", id.index())) - .unwrap_or_default() - )), - remote = remote_ref_name - .as_ref() - .map(|remote_ref_name| format!( - " <> {remote_name}{maybe_id}", - remote_name = Graph::ref_debug_string(remote_ref_name.as_ref(), None), - maybe_id = remote_tracking_branch_id - .or(sibling_id) - .map(|id| format!(" โ†’:{}:", id.index())) - .unwrap_or_default() - )) - .unwrap_or_default() - ) - } +pub(crate) fn ref_debug_string_inner( + ref_name: &gix::refs::FullNameRef, + worktree: Option<&crate::Worktree>, + show_owned_by_repo: bool, +) -> String { + let (cat, sn) = ref_name.category_and_short_name().expect("valid refs"); + // Only shorten those that look good and are unambiguous enough. + format!( + "{}{ws}", + if matches!(cat, Category::LocalBranch | Category::RemoteBranch) { + sn + } else { + ref_name + .as_bstr() + .strip_prefix(b"refs/") + .map(|n| n.as_bstr()) + .unwrap_or(ref_name.as_bstr()) + }, + ws = worktree + .map(|wt| wt.debug_string_with_graph_context(ref_name, show_owned_by_repo)) + .unwrap_or_default() + ) +} - /// 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(unix)] - pub fn validated_or_open_as_svg(self) -> anyhow::Result { - use petgraph::visit::IntoEdgeReferences; - for edge in self.inner.edge_references() { - let res = Self::check_edge(&self.inner, edge, false); - if res.is_err() { - self.open_as_svg(); - } - res?; - } - Ok(self) - } +/// Return a useful one-line string showing the relationship between `ref_name`, `remote_ref_name` and how +/// they are linked with `sibling_id` and `remote_tracking_branch_id`. +pub(crate) fn ref_and_remote_debug_string( + ref_info: Option<&crate::RefInfo>, + remote_ref_name: Option<&gix::refs::FullName>, + sibling_id: Option, + remote_tracking_branch_id: Option, +) -> String { + ref_and_remote_debug_string_inner( + ref_info, + remote_ref_name, + sibling_id, + remote_tracking_branch_id, + false, + ) +} - /// Output this graph in dot-format to stderr to allow copying it, and using like this for visualization: - /// - /// ```shell - /// pbpaste | dot -Tsvg >graph.svg && open graph.svg - /// ``` - /// - /// Note that this may reveal additional debug information when invariants of the graph are violated. - /// This often is more useful than seeing a hard error, which can be achieved with `Self::validated()` - pub fn eprint_dot_graph(&self) { - let dot = self.dot_graph_pruned(); - eprintln!("{dot}"); - } +pub(crate) fn ref_and_remote_debug_string_inner( + ref_info: Option<&crate::RefInfo>, + remote_ref_name: Option<&gix::refs::FullName>, + sibling_id: Option, + remote_tracking_branch_id: Option, + show_owned_by_repo: bool, +) -> String { + format!( + "{ref_name}{remote}", + ref_name = ref_info + .as_ref() + .map(|ri| format!( + "{}{maybe_id}", + ref_debug_string_inner( + ri.ref_name.as_ref(), + ri.worktree.as_ref(), + show_owned_by_repo, + ), + maybe_id = sibling_id + .filter(|_| remote_ref_name.is_none()) + .map(|id| format!(" โ†’:{id}:")) + .unwrap_or_default() + )) + .unwrap_or_else(|| format!( + "anon:{maybe_id}", + maybe_id = sibling_id.map(|id| format!(" โ†’:{id}:")).unwrap_or_default() + )), + remote = remote_ref_name + .as_ref() + .map(|remote_ref_name| format!( + " <> {remote_name}{maybe_id}", + remote_name = ref_debug_string(remote_ref_name.as_ref(), None), + maybe_id = remote_tracking_branch_id + .or(sibling_id) + .map(|id| format!(" โ†’:{id}:")) + .unwrap_or_default() + )) + .unwrap_or_default() + ) +} - /// Open an SVG dot visualization in the browser or panic if the `dot` or `open` tool can't be found. - #[cfg(unix)] - #[tracing::instrument(skip(self))] - pub fn open_as_svg(&self) { +/// Render `dot` with Graphviz into an SVG next to the manifest and open it, or panic if +/// the `dot` or `open` tool can't be found. +#[cfg(unix)] +pub(crate) fn open_dot_as_svg(dot_document: &str) { + { use std::{io::Write, process::Stdio, sync::atomic::AtomicUsize}; static SUFFIX: AtomicUsize = AtomicUsize::new(0); @@ -373,7 +375,7 @@ impl Graph { dot.stdin .as_mut() .unwrap() - .write_all(self.dot_graph_pruned().as_bytes()) + .write_all(dot_document.as_bytes()) .ok(); let mut out = dot.wait_with_output().unwrap(); out.stdout.extend(out.stderr); @@ -393,223 +395,4 @@ impl Graph { svg_path = svg_path.display() ); } - - /// Return the highest amount of goals that a commit has stored in its flags. - /// - /// This relates to the amount of commits who were tracked for reachability, i.e. allowing an ancestor to see - /// if a particular commit is in its future. - pub fn max_goals(&self) -> Option { - self.node_weights() - .filter_map(|s| s.commits.iter().map(|c| c.flags.num_goals()).max()) - .max() - } - - /// Return `true` if more than one unique worktree is referenced by the graph. - pub(crate) fn has_multiple_worktrees(&self) -> bool { - let mut first: Option<&crate::WorktreeKind> = None; - self.node_weights() - .flat_map(|segment| { - segment - .ref_info - .iter() - .chain(segment.commits.iter().flat_map(|commit| commit.refs.iter())) - }) - .filter_map(|ref_info| ref_info.worktree.as_ref()) - .any(|worktree| { - if let Some(first) = first { - first != &worktree.kind - } else { - first = Some(&worktree.kind); - false - } - }) - } - - /// Produces a pruned dot-version of the graph. - /// - /// This best-effort rendering path prunes very large graphs from the bottom - /// before handing them to Graphviz, because complete DOT input can make - /// `dot` hang on huge histories. It tries to compute the workspace lower - /// bound to preserve workspace-relevant segments and clear stale - /// `InWorkspace` flags below that bound, but projection is allowed to fail: - /// DOT output is diagnostic-only, so a workspace projection error must not - /// prevent rendering the graph. - pub fn dot_graph_pruned(&self) -> String { - let mut display_graph = self.clone(); - display_graph.prune_for_dot_graph(); - display_graph.dot_graph_unpruned() - } - - /// Produces a dot-version of the graph without pruning. - fn dot_graph_unpruned(&self) -> String { - const HEX: usize = 7; - let entrypoint = self.entrypoint_location(); - let max_goals = self.max_goals(); - let show_owned_by_repo = self.has_multiple_worktrees(); - let node_attrs = |_: &PetGraph, (sidx, s): (SegmentIndex, &Segment)| { - let name = format!( - "{ref_name_and_remote}{maybe_centering_newline}", - ref_name_and_remote = Self::ref_and_remote_debug_string_inner( - s.ref_info.as_ref(), - s.remote_tracking_ref_name.as_ref(), - s.sibling_segment_id, - s.remote_tracking_branch_segment_id, - show_owned_by_repo, - ), - maybe_centering_newline = if s.commits.is_empty() { "" } else { "\n" }, - ); - // Reduce noise by preferring ref-based entry-points. - let show_segment_entrypoint = s.ref_info.is_some() - && entrypoint.is_some_and(|(s, cidx)| s == sidx && matches!(cidx, None | Some(0))); - let mut commits = s - .commits - .iter() - .enumerate() - .map(|(cidx, c)| { - Self::commit_debug_string_inner( - c, - !show_segment_entrypoint && Some((sidx, Some(cidx))) == entrypoint, - if cidx + 1 != s.commits.len() { - None - } else { - self.stop_condition(sidx) - }, - self.hard_limit_hit, - max_goals, - show_owned_by_repo, - ) - }) - .collect::>() - .join("\\l"); - let max_dot_label_size = 16384 - 384 /* safety buffer for everything else in the label */; - if commits.len() > max_dot_label_size { - let new_len = commits - .char_indices() - .rev() - .find(|(idx, _)| *idx < max_dot_label_size) - .expect("there must be one") - .0; - let cut = commits.len() - new_len; - commits.truncate(new_len); - commits.push_str(&format!("[{cut} bytes cut]โ€ฆ\\l")); - } - format!( - ", shape = box, label = \"{entrypoint}{meta}:{id}[{generation}]:{name}{commits}\\l\", fontname = Courier, margin = 0.2", - meta = match s.metadata { - None => { - "" - } - Some(SegmentMetadata::Workspace(_)) => { - "๐Ÿ“•" - } - Some(SegmentMetadata::Branch(_)) => { - "๐Ÿ“™" - } - }, - entrypoint = if show_segment_entrypoint { "๐Ÿ‘‰" } else { "" }, - id = sidx.index(), - generation = s.generation, - ) - }; - - 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) = Self::check_edge(g, e, true) 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:?}") - } - - // WARNING: should only be run on a fresh clone as it probably leaves the graph unusable. - fn prune_for_dot_graph(&mut self) { - let lower_bound_segment_id = self - .to_workspace_state(crate::workspace::workspace::Downgrade::Allow) - .ok() - .and_then(|state| state.lower_bound_segment_id); - if let Some(lower_bound_segment_id) = lower_bound_segment_id { - self.remove_in_workspace_flag_below_lower_bound(lower_bound_segment_id); - } - - // It's OK if it takes a while, prefer complete graphs. - const LIMIT: usize = 5000; - let mut to_remove = self.num_segments().saturating_sub(LIMIT); - if to_remove > 0 { - tracing::warn!( - "Pruning at most {to_remove} nodes from the bottom to assure 'dot' won't hang", - ); - let mut next = VecDeque::new(); - next.extend(self.base_segments()); - let mut seen = self.seen_table(); - while let Some(sidx) = next.pop_front() { - if to_remove == 0 { - break; - } - if let Some(s) = self.node_weight(sidx) { - if lower_bound_segment_id.is_some() - && s.non_empty_flags_of_first_commit() - .is_some_and(|flags| flags.contains(CommitFlags::InWorkspace)) - { - continue; - } - if s.metadata.is_some() - || s.sibling_segment_id.is_some() - || s.remote_tracking_branch_segment_id.is_some() - { - continue; - } - } - next.extend( - self.neighbors_directed(sidx, petgraph::Direction::Incoming) - .filter(|n| seen.insert_unseen(*n)), - ); - self.remove_node(sidx); - to_remove -= 1; - } - if to_remove != 0 { - tracing::warn!( - "{to_remove} extra nodes were kept to keep vital portions of the graph" - ); - } - self.set_hard_limit_hit(); - } - } - - fn remove_in_workspace_flag_below_lower_bound(&mut self, lower_bound_segment_id: SegmentIndex) { - let mut seen = self.seen_table(); - seen.insert_unseen(lower_bound_segment_id); - let mut queue = VecDeque::from([lower_bound_segment_id]); - while let Some(sidx) = queue.pop_front() { - let below_segments: Vec<_> = self - .neighbors_directed(sidx, petgraph::Direction::Outgoing) - .filter(|n| seen.insert_unseen(*n)) - .collect(); - for below_sidx in below_segments { - if let Some(segment) = self.node_weight_mut(below_sidx) - && let Some(first_commit) = segment.commits.first_mut() - { - first_commit.flags.remove(CommitFlags::InWorkspace); - } - queue.push_back(below_sidx); - } - } - } } diff --git a/crates/but-graph/src/init/overlay.rs b/crates/but-graph/src/init/overlay.rs deleted file mode 100644 index 10a85112e88..00000000000 --- a/crates/but-graph/src/init/overlay.rs +++ /dev/null @@ -1,533 +0,0 @@ -use std::{ - borrow::Cow, - collections::{BTreeMap, BTreeSet, HashSet}, -}; - -use anyhow::bail; -use but_core::{RefMetadata, ref_metadata}; -use gix::{prelude::ReferenceExt, refs::Target}; - -use crate::{ - Worktree, WorktreeKind, - init::{ - Entrypoint, Overlay, - walk::{RefsById, WorktreeByBranch}, - }, -}; - -impl Overlay { - /// Serve the given `refs` from memory, as if they would exist. - /// This is true only, however, if a real reference doesn't exist. - pub fn with_references_if_new( - mut self, - refs: impl IntoIterator, - ) -> Self { - self.nonoverriding_references = refs.into_iter().collect(); - self - } - - /// Serve the given `refs` from memory, which is like creating the reference or as if its value was set, - /// completely overriding the value in the repository. - pub fn with_references(mut self, refs: impl IntoIterator) -> Self { - self.overriding_references.extend(refs); - self - } - - /// A list of references that should not be picked up anymore in the - /// re-traversal. - /// - /// For example, if the `but_rebase::graph_rebase::Editor` converts a - /// `Reference` step to a `None` step which is the equivalent of running - /// `git update-ref -d`, it should no longer be part of the - /// [`crate::Graph`], so we would list the particular reference as a dropped - /// reference. - pub fn with_dropped_references( - mut self, - refs: impl IntoIterator, - ) -> Self { - self.dropped_references.extend(refs); - self - } - - /// Override the starting position of the traversal by setting it to `id`, - /// and optionally, by providing the `ref_name` that points to `id`. - pub fn with_entrypoint( - mut self, - id: gix::ObjectId, - ref_name: Option, - ) -> Self { - if let Some((_id, ref_name)) = self.entrypoint { - self.overriding_references - .retain(|r| Some(&r.name) != ref_name.as_ref()) - } - - if let Some(ref_name) = &ref_name { - self.overriding_references.push(gix::refs::Reference { - name: ref_name.to_owned(), - target: Target::Object(id), - peeled: Some(id), - }) - } - self.entrypoint = Some((id, ref_name)); - self - } - - /// Serve the given `branches` metadata from memory, as if they would exist, - /// possibly overriding metadata of a ref that already exists. - pub fn with_branch_metadata_override( - mut self, - refs: impl IntoIterator, - ) -> Self { - self.meta_branches = refs.into_iter().collect(); - self - } - - /// Serve the given workspace `metadata` from memory, as if they would exist, - /// possibly overriding metadata of a workspace at that place - pub fn with_workspace_metadata_override( - mut self, - metadata: Option<(gix::refs::FullName, ref_metadata::Workspace)>, - ) -> Self { - self.workspace = metadata; - self - } - - /// Serve the given ad-hoc branch stack order from memory. - pub fn with_branch_stack_order_override( - mut self, - branches: impl IntoIterator, - ) -> Self { - self.branch_stack_orders - .push(branches.into_iter().collect()); - self - } -} - -impl Overlay { - pub(crate) fn into_parts<'repo, 'meta, T>( - self, - repo: &'repo gix::Repository, - meta: &'meta T, - ) -> (OverlayRepo<'repo>, OverlayMetadata<'meta, T>, Entrypoint) - where - T: RefMetadata, - { - let Overlay { - nonoverriding_references, - overriding_references, - dropped_references, - meta_branches, - branch_stack_orders, - workspace, - entrypoint, - } = self; - // Construct BTreeMaps with a deterministic order from left to right. - let mut or = BTreeMap::new(); - for reference in overriding_references { - if !or.contains_key(&reference.name) { - or.insert(reference.name.clone(), reference); - } - } - let mut nor = BTreeMap::new(); - for reference in nonoverriding_references { - if !nor.contains_key(&reference.name) { - nor.insert(reference.name.clone(), reference); - } - } - - ( - OverlayRepo { - nonoverriding_references: nor, - overriding_references: or, - dropped_references: dropped_references.into_iter().collect(), - inner: repo, - }, - OverlayMetadata { - inner: meta, - meta_branches, - branch_stack_orders, - workspace, - }, - entrypoint, - ) - } -} - -type NameToReference = BTreeMap; - -pub(crate) struct OverlayRepo<'repo> { - inner: &'repo gix::Repository, - nonoverriding_references: NameToReference, - overriding_references: NameToReference, - dropped_references: BTreeSet, -} - -/// Note that functions with `'repo` in their return value technically leak the bare repo, and it's -/// up to us to ensure it's not actually used directly, or only such that the in-memory feature isn't bypassed. -impl<'repo> OverlayRepo<'repo> { - pub fn commit_graph_if_enabled(&self) -> anyhow::Result> { - Ok(self.inner.commit_graph_if_enabled()?) - } - - pub fn shallow_commits( - &self, - ) -> Result, gix::shallow::read::Error> { - self.inner.shallow_commits() - } - - pub fn try_find_reference( - &self, - ref_name: &gix::refs::FullNameRef, - ) -> anyhow::Result>> { - if self.dropped_references.contains(ref_name) { - Ok(None) - } else if let Some(r) = self.overriding_references.get(ref_name) { - Ok(Some(r.clone().attach(self.inner))) - } else if let Some(rn) = self.inner.try_find_reference(ref_name)? { - Ok(Some(rn)) - } else if let Some(r) = self.nonoverriding_references.get(ref_name) { - Ok(Some(r.clone().attach(self.inner))) - } else { - Ok(None) - } - } - - pub fn find_reference( - &self, - ref_name: &gix::refs::FullNameRef, - ) -> anyhow::Result> { - if self.dropped_references.contains(ref_name) { - bail!( - "Failed to find reference {ref_name} due to it being dropped in the traversal overlay" - ); - } - if let Some(r) = self.overriding_references.get(ref_name) { - return Ok(r.clone().attach(self.inner)); - } - Ok(self - .inner - .find_reference(ref_name) - .or_else(|err| match err { - gix::reference::find::existing::Error::Find(_) => Err(err), - gix::reference::find::existing::Error::NotFound { .. } => { - if let Some(r) = self.nonoverriding_references.get(ref_name) { - Ok(r.clone().attach(self.inner)) - } else { - Err(err) - } - } - })?) - } - - pub fn config_snapshot(&self) -> gix::config::Snapshot<'repo> { - self.inner.config_snapshot() - } - - pub fn branch_remote_tracking_ref_name( - &self, - name: &gix::refs::FullNameRef, - direction: gix::remote::Direction, - ) -> Option< - Result< - Cow<'_, gix::refs::FullNameRef>, - gix::repository::branch_remote_tracking_ref_name::Error, - >, - > { - self.inner.branch_remote_tracking_ref_name(name, direction) - } - - pub fn find_commit(&self, id: gix::ObjectId) -> anyhow::Result> { - Ok(self.inner.find_commit(id)?) - } - - pub fn for_attach_only(&self) -> &'repo gix::Repository { - self.inner - } - - pub fn for_find_only(&self) -> &'repo gix::Repository { - self.inner - } - - pub fn remote_names(&self) -> gix::remote::Names<'repo> { - self.inner.remote_names() - } - - pub fn upstream_branch_and_remote_for_tracking_branch( - &self, - name: &gix::refs::FullNameRef, - ) -> anyhow::Result)>> { - Ok(self - .inner - .upstream_branch_and_remote_for_tracking_branch(name)?) - } - - /// Create a mapping of all heads to the object ids they point to. - /// `workspace_ref_names` is the names of all known workspace references. - pub fn collect_ref_mapping_by_prefix<'a>( - &self, - prefixes: impl Iterator, - workspace_ref_names: &[&gix::refs::FullNameRef], - ) -> anyhow::Result { - let mut seen = HashSet::new(); - let mut ref_filter = - |r: gix::Reference<'_>| -> Option<(gix::ObjectId, gix::refs::FullName)> { - if self.dropped_references.contains(r.name()) { - return None; - } - - if workspace_ref_names.contains(&r.name()) { - return None; - } - let id = r.try_id()?; - let (id, name) = - if matches!(r.name().category(), Some(gix::reference::Category::Tag)) { - // TODO: also make use of the tag name (the tag object has its own name) - (id.object().ok()?.peel_tags_to_end().ok()?.id, r.inner.name) - } else { - (id.detach(), r.inner.name) - }; - // This is only for overrides. - seen.insert(name.clone()).then_some((id, name)) - }; - let mut all_refs_by_id = gix::hashtable::HashMap::<_, Vec<_>>::default(); - for prefix in prefixes { - // apply overrides - they are seen first and take the spot of everything. - for (commit_id, git_reference) in self - .overriding_references - .values() - .filter(|rn| rn.name.as_bstr().starts_with(prefix.as_bytes())) - .filter_map(|rn| ref_filter(rn.clone().attach(self.inner))) - { - all_refs_by_id - .entry(commit_id) - .or_default() - .push(git_reference); - } - for (commit_id, git_reference) in self - .inner - .references()? - .prefixed(prefix)? - .filter_map(Result::ok) - .filter_map(&mut ref_filter) - { - all_refs_by_id - .entry(commit_id) - .or_default() - .push(git_reference); - } - // apply overrides (new only) - for (commit_id, git_reference) in self - .nonoverriding_references - .values() - .filter(|rn| rn.name.as_bstr().starts_with(prefix.as_bytes())) - .filter_map(|rn| ref_filter(rn.clone().attach(self.inner))) - { - all_refs_by_id - .entry(commit_id) - .or_default() - .push(git_reference); - } - } - all_refs_by_id.values_mut().for_each(|v| v.sort()); - Ok(all_refs_by_id) - } - - /// This is a bit tricky but aims to map the `HEAD` targets of the main worktree to what possibly was overridden - /// via `main_head_referent`. The idea is that this is the entrypoint, which is assumed to be `HEAD` - /// - /// ### Shortcoming - /// - /// For now, it can only remap the first HEAD reference. For this to really work, we need proper in-memory overrides - /// or a way to have overrides 'for real'. - /// Also, we don't want `main_head_referent` to be initialised from the entrypoint, which we equal to be `HEAD`. - /// But this invariant can fall apart easily and is caller dependent, as we use it to see the graph *as if* `HEAD` would - /// be in another position - but that doesn't affect the worktree ref at all. - pub fn worktree_branches( - &self, - main_head_referent: Option<&gix::refs::FullNameRef>, - ) -> anyhow::Result { - /// If `main_head_referent` is set, it means this is an overridden reference of the `HEAD` of the repo the graph is built in. - /// If `None`, `head` belongs to another worktree. Completely unrelated to linked or main. - fn maybe_insert_head( - head: Option>, - main_head_referent: Option<&gix::refs::FullNameRef>, - overriding: &NameToReference, - out: &mut WorktreeByBranch, - owned_by_repo: bool, - ) -> anyhow::Result<()> { - let Some((head, wd)) = head.and_then(|head| { - head.repo.worktree().map(|wt| { - ( - head, - Worktree { - kind: match wt.id() { - None => WorktreeKind::Main, - Some(id) => WorktreeKind::LinkedId(id.to_owned()), - }, - owned_by_repo, - }, - ) - }) - }) else { - return Ok(()); - }; - - out.entry("HEAD".try_into().expect("valid")) - .or_default() - .push(wd.clone()); - let mut ref_chain = Vec::new(); - // Is this the repo that the overrides were applied on? - let mut cursor = if let Some(head_name) = main_head_referent { - overriding - .get(head_name) - .map(|overridden_head| overridden_head.clone().attach(head.repo)) - .or_else(|| head.try_into_referent()) - } else { - head.try_into_referent() - }; - while let Some(ref_) = cursor { - ref_chain.push(ref_.name().to_owned()); - if overriding - .get(ref_.name()) - .is_some_and(|r| r.target.try_name() != ref_.target().try_name()) - { - bail!( - "SHORTCOMING: cannot deal with {ref_:?} overridden to a different symbolic name to follow" - ) - } - cursor = ref_.follow().transpose()?; - } - for name in ref_chain { - out.entry(name).or_default().push(wd.clone()); - } - - Ok(()) - } - - let mut map = BTreeMap::new(); - maybe_insert_head( - self.inner.head().ok(), - main_head_referent, - &self.overriding_references, - &mut map, - true, - )?; - - let mut repo_is_linked = false; - let current_dir = std::env::current_dir()?; - let repo_real_path = gix::path::realpath_opts( - self.inner.path(), - ¤t_dir, - gix::path::realpath::MAX_SYMLINKS, - )?; - for proxy in self.inner.worktrees()? { - let repo = proxy.into_repo_with_possibly_inaccessible_worktree()?; - let linked_repo_real_path = gix::path::realpath_opts( - repo.path(), - ¤t_dir, - gix::path::realpath::MAX_SYMLINKS, - )?; - if linked_repo_real_path == repo_real_path { - repo_is_linked = true; - continue; - } - maybe_insert_head( - repo.head().ok(), - None, - &self.overriding_references, - &mut map, - false, - )?; - } - if repo_is_linked && let Ok(main_repo) = self.inner.main_repo() { - maybe_insert_head( - main_repo.head().ok(), - None, - &self.overriding_references, - &mut map, - false, - )?; - } - Ok(map) - } -} - -pub(crate) struct OverlayMetadata<'meta, T> { - inner: &'meta T, - meta_branches: Vec<(gix::refs::FullName, ref_metadata::Branch)>, - branch_stack_orders: Vec>, - workspace: Option<(gix::refs::FullName, ref_metadata::Workspace)>, -} - -impl OverlayMetadata<'_, T> -where - T: RefMetadata, -{ - pub fn iter_workspaces( - &self, - ) -> impl Iterator { - self.inner - .iter() - .filter_map(Result::ok) - .filter_map(|(ref_name, item)| { - item.downcast::() - .ok() - .map(|ws| (ref_name, ws)) - }) - .map(|(ref_name, ws)| { - if let Some((_ws_ref, ws_override)) = self - .workspace - .as_ref() - .filter(|(ws_ref, _ws_data)| *ws_ref == ref_name) - { - (ref_name, ws_override.clone()) - } else { - (ref_name, (*ws).clone()) - } - }) - } - - pub fn workspace_opt( - &self, - ref_name: &gix::refs::FullNameRef, - ) -> anyhow::Result> { - if let Some((_ws_ref, ws_meta)) = self - .workspace - .as_ref() - .filter(|(ws_ref, _ws_meta)| ws_ref.as_ref() == ref_name) - { - return Ok(Some(ws_meta.clone())); - } - let opt = self.inner.workspace_opt(ref_name)?; - Ok(opt.map(|ws_data| ws_data.clone())) - } - - pub fn branch_opt( - &self, - ref_name: &gix::refs::FullNameRef, - ) -> anyhow::Result> { - if let Some(overlay_branch) = self - .meta_branches - .iter() - .find_map(|(rn, branch)| (rn.as_ref() == ref_name).then(|| branch.clone())) - { - return Ok(Some(overlay_branch)); - } - let opt = self.inner.branch_opt(ref_name)?; - Ok(opt.map(|data| data.clone())) - } - - pub fn branch_stack_order( - &self, - ref_name: &gix::refs::FullNameRef, - ) -> anyhow::Result>> { - if let Some(branches) = self - .branch_stack_orders - .iter() - .find(|branches| branches.iter().any(|branch| branch.as_ref() == ref_name)) - { - return Ok(Some(branches.clone())); - } - self.inner.branch_stack_order(ref_name) - } -} diff --git a/crates/but-graph/src/init/remotes.rs b/crates/but-graph/src/init/remotes.rs deleted file mode 100644 index 9ca1f4dd631..00000000000 --- a/crates/but-graph/src/init/remotes.rs +++ /dev/null @@ -1,71 +0,0 @@ -use std::collections::BTreeSet; - -use gix::reference::Category; - -use crate::init::overlay::OverlayRepo; - -/// Returns the unique names of all remote tracking branches that are configured in the repository. -/// Useful to avoid claiming them for deduction. -pub fn configured_remote_tracking_branches( - repo: &OverlayRepo<'_>, -) -> anyhow::Result> { - let mut out = BTreeSet::default(); - for short_name in repo - .config_snapshot() - .sections_by_name("branch") - .into_iter() - .flatten() - .filter_map(|s| s.header().subsection_name()) - { - let Ok(full_name) = Category::LocalBranch.to_full_name(short_name) else { - continue; - }; - out.extend(lookup_remote_tracking_branch(repo, full_name.as_ref())?); - } - Ok(out) -} - -// Note that despite having multiple candidates for remote names, there can only be one -// remote per branch. -// TODO: remove deduction entirely by properly setting up remotes. -pub fn lookup_remote_tracking_branch_or_deduce_it( - repo: &OverlayRepo<'_>, - ref_name: &gix::refs::FullNameRef, - symbolic_remote_names: &[String], - configured_remote_tracking_branches: &BTreeSet, -) -> anyhow::Result> { - Ok(lookup_remote_tracking_branch(repo, ref_name)?.or_else(|| { - for symbolic_remote_name in symbolic_remote_names { - // Deduce the ref-name as fallback. - // TODO: remove this - this is only required to support legacy repos that - // didn't setup normal Git remotes. - let remote_tracking_ref_name = format!( - "refs/remotes/{symbolic_remote_name}/{short_name}", - short_name = ref_name.shorten() - ); - let Ok(remote_tracking_ref_name) = - gix::refs::FullName::try_from(remote_tracking_ref_name) - else { - continue; - }; - if configured_remote_tracking_branches.contains(&remote_tracking_ref_name) { - continue; - } - return repo - .find_reference(remote_tracking_ref_name.as_ref()) - .ok() - .map(|remote_ref| remote_ref.name().to_owned()); - } - None - })) -} - -pub fn lookup_remote_tracking_branch( - repo: &OverlayRepo<'_>, - ref_name: &gix::refs::FullNameRef, -) -> anyhow::Result> { - Ok(repo - .branch_remote_tracking_ref_name(ref_name, gix::remote::Direction::Fetch) - .transpose()? - .map(|rn| rn.into_owned())) -} diff --git a/crates/but-graph/src/init/types.rs b/crates/but-graph/src/init/types.rs deleted file mode 100644 index 2c5a8feea54..00000000000 --- a/crates/but-graph/src/init/types.rs +++ /dev/null @@ -1,604 +0,0 @@ -use std::{ - collections::{BTreeSet, VecDeque}, - ops::Range, -}; - -use petgraph::{Direction, prelude::EdgeRef, stable_graph::EdgeReference}; - -use crate::{CommitFlags, CommitIndex, Edge, SegmentIndex}; - -#[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. - /// - /// ### 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 - } - - /// Set two or more goals, by setting `goal` directly as previously obtained by [Goals::flag_for()]. - pub fn additional_goal(mut self, goal: CommitFlags) -> Self { - self.goal |= goal; - 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. - if let Some(maybe_goal) = self.goal_reachable(flags) - && (maybe_goal.is_empty() || self.set_single_goal_reached_keep_searching(maybe_goal)) - { - return false; - } - // 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. - /// Return `true` if all goals are reached now. - pub fn set_single_goal_reached_keep_searching(&mut self, goal: CommitFlags) -> bool { - self.goal.remove(goal); - if self.goal.is_empty() { - self.goal.insert(CommitFlags::Integrated); - false - } else { - true - } - } - - /// If `other` has a higher limit as ourselves, apply the higher limit to us. - /// Nothing else is affected. - pub fn adjust_limit_if_bigger(&mut self, other: Limit) { - match (&mut self.inner, other.inner) { - (inner @ Some(_), None) => *inner = None, - (Some(x), Some(y)) => { - if *x < y { - *x = y; - } - } - (None, None) | (None, Some(_)) => {} - } - } - - 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 `!CommitFlags::empty()` 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.intersection(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; - } -} - -/// Lifecycle -impl Queue { - pub fn new_with_limit(limit: Option) -> Self { - Queue { - inner: Default::default(), - count: 0, - max: limit, - hard_limit_hit: false, - exhausted: false, - sorted: false, - } - } -} - -/// A queue to keep track of tips, which additionally counts how much was queued over time. -#[derive(Debug)] -pub struct Queue { - pub inner: VecDeque, - /// The current number of queued items. - count: usize, - /// The maximum number of queuing operations, each representing one commit. - max: Option, - /// Whether the hard limit stopped further queuing at least once. - hard_limit_hit: bool, - /// Whether no more items should be queued for reasons other than the hard limit. - exhausted: bool, - /// Whether new items must maintain `inner` in traversal order. - sorted: bool, -} - -/// Counted queuing -impl Queue { - /// Sort the queue items so that young commits come first. This way, the traversal goes - /// back in time continuously, which helps to avoid having too many graph traversals - /// in disjoint regions happen at the same time. - /// Note that traversals sorted like this are much less prone to run into the `propagate_flags_downward` - /// bottleneck. While they may (depending on the graph) create their own bottleneck if they end up missing - /// their point of interest and overshoot to the beginning of time, this is still preferable over the flag - /// propagation bottleneck. This is true Particularly if a commit-graph exists which typically is the case - /// where this starts to matter, as it speeds up traversal by factor 8 easily. - pub fn sort(&mut self) { - if !self.sorted { - self.inner - .make_contiguous() - .sort_by(|a, b| a.0.gen_then_time.cmp(&b.0.gen_then_time)); - self.sorted = true; - } - } - #[must_use] - pub fn push_back_exhausted(&mut self, item: QueueItem) -> bool { - if self.exhausted || self.record_hard_limit_if_exhausted() { - return true; - } - self.push_back_even_if_exhausted(item) - } - - pub(crate) fn push_back_even_if_exhausted(&mut self, item: QueueItem) -> bool { - if self.sorted { - self.insert_sorted(item); - } else { - self.inner.push_back(item); - } - self.is_exhausted_after_increment() - } - #[must_use] - pub fn push_front_exhausted(&mut self, item: QueueItem) -> bool { - if self.exhausted || self.record_hard_limit_if_exhausted() { - return true; - } - if self.sorted { - self.insert_sorted(item); - } else { - self.inner.push_front(item); - } - self.is_exhausted_after_increment() - } - - fn insert_sorted(&mut self, item: QueueItem) { - let index = self - .inner - .partition_point(|existing| existing.0.gen_then_time <= item.0.gen_then_time); - self.inner.insert(index, item); - } - - fn is_exhausted_after_increment(&mut self) -> bool { - self.count += 1; - self.exhausted || self.record_hard_limit_if_exhausted() - } - - pub fn is_exhausted(&self) -> bool { - self.exhausted || self.is_hard_limit_exhausted() - } - - pub(crate) fn is_hard_limit_exhausted(&self) -> bool { - self.max.is_some_and(|l| self.count >= l) - } - - pub(crate) fn hard_limit_hit(&self) -> bool { - self.hard_limit_hit - } - - fn record_hard_limit_if_exhausted(&mut self) -> bool { - let hard_limit_exhausted = self.is_hard_limit_exhausted(); - self.hard_limit_hit |= hard_limit_exhausted; - hard_limit_exhausted - } - - /// Stop accepting new items while leaving already queued items to drain. - pub(crate) fn exhaust(&mut self) { - self.exhausted = true; - } - - /// Add `goal` as additional goal to `id` or panic if `id` was not found. - pub fn add_goal_to(&mut self, id: gix::ObjectId, goal: CommitFlags) { - let limit = self - .inner - .iter_mut() - .find_map(|(info, _, _, limit)| (info.id == id).then_some(limit)) - .unwrap_or_else(|| panic!("BUG: {id} is queued")); - *limit = limit.additional_goal(goal); - } -} - -/// 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() - } -} -/// A set of commits to keep track of in bitflags. -#[derive(Default)] -pub struct Goals(Vec); - -impl Goals { - /// Return the bitflag for `goal`, or `None` if we can't track any more goals. - pub 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)] -pub enum Instruction { - /// Contains the segment into which to place this commit. - CollectCommit { into: SegmentIndex }, - /// This is the first commit in a new segment which is below `parent_above` and which should be placed - /// at the last commit (at the time) via `at_commit`. - ConnectNewSegment { - parent_above: SegmentIndex, - /// Deliberately not [`CommitIndex`]/`usize`: this instruction is stored in - /// the traversal queue, and widening it increases the hot `QueueItem` size. - /// - /// This limit should never be reached either unless there is a repository with a trunk of 4.3 billion commits. - at_commit: u32, - /// The position of this parent among the source commit's parents (0-based). - /// Deliberately not `usize` for the same traversal-queue layout reason as `at_commit`. - /// - /// This limit would only be reached if there is a merge with 4.3 billion commits. - parent_order: u32, - }, -} - -impl Instruction { - /// Returns any segment index we may be referring to. - pub fn segment_idx(&self) -> SegmentIndex { - match self { - Instruction::CollectCommit { into } => *into, - Instruction::ConnectNewSegment { parent_above, .. } => *parent_above, - } - } - - pub fn with_replaced_sidx(self, sidx: SegmentIndex) -> Self { - match self { - Instruction::CollectCommit { into: _ } => Instruction::CollectCommit { into: sidx }, - Instruction::ConnectNewSegment { - parent_above: _, - at_commit, - parent_order, - } => Instruction::ConnectNewSegment { - parent_above: sidx, - at_commit, - parent_order, - }, - } - } -} - -pub type QueueItem = (super::walk::TraverseInfo, CommitFlags, Instruction, Limit); - -#[derive(Debug)] -pub(crate) struct EdgeOwned { - pub source: SegmentIndex, - pub target: SegmentIndex, - pub weight: Edge, - pub id: petgraph::graph::EdgeIndex, -} - -impl From> for EdgeOwned { - fn from(e: EdgeReference<'_, Edge>) -> Self { - EdgeOwned { - source: e.source(), - target: e.target(), - weight: *e.weight(), - id: e.id(), - } - } -} - -/// A custom topological walk that always goes in a given [direction](Direction), -/// does not need a graph reference, and which yields ranges of non-overlapping commit in a segment. -/// -/// This walk assumes the worst-case graph where edges point to any commit, even from commits that -/// aren't the first one or target commits that aren't the first one in a segment. -/// -/// ### Note -/// -/// In theory, a normal [`petgraph::visit::Topo`] would do here, if we assume that everything works -/// as it should. So at some point, this code might be removed once it's clear we won't need it anymore. -/// TODO: one fine day remove this in favor of `petgraph::visit::Topo`. -pub struct TopoWalk { - /// The segment we - next: VecDeque<(SegmentIndex, Option)>, - /// Commits we have already yielded. - seen: gix::hashtable::HashSet, - /// If segments have no commits we can't identify them unless we track them separately. - seen_empty_segments: BTreeSet, - /// In which direction to traverse. - direction: Direction, - /// If `true`, don't return the first segment which is always the starting point. - skip_tip: Option<()>, - /// If this is set during the iteration, we will store segment ids which didn't have any outgoing or - /// incoming connections, depending on the direction of traversal. - pub leafs: Option>, -} - -/// Lifecycle -impl TopoWalk { - /// Start a walk at `segment`, possibly only from `commit`. - pub fn start_from( - segment: SegmentIndex, - commit: Option, - direction: Direction, - ) -> Self { - TopoWalk { - next: { - let mut v = VecDeque::new(); - v.push_back((segment, commit)); - v - }, - seen: Default::default(), - seen_empty_segments: Default::default(), - direction, - skip_tip: None, - leafs: None, - } - } -} - -/// Builder -impl TopoWalk { - /// Call to not return the tip as part of the iteration. - pub fn skip_tip_segment(mut self) -> Self { - self.skip_tip = Some(()); - self - } -} - -/// Iteration -impl TopoWalk { - /// Obtain the next segment and unseen commit range in the topo-walk. - /// Note that the returned range may yield an empty slice, or a sub-slice - /// of all available commits. - pub fn next( - &mut self, - graph: &crate::init::PetGraph, - ) -> Option<(SegmentIndex, Range)> { - while !self.next.is_empty() { - let res = self.next_inner(graph); - if res.is_some() { - if self.skip_tip.take().is_some() { - continue; - } - return res; - } - } - None - } - - fn next_inner( - &mut self, - graph: &crate::init::PetGraph, - ) -> Option<(SegmentIndex, Range)> { - let (segment, first_commit_index) = self.next.pop_front()?; - let available_range = self.select_range(graph, segment, first_commit_index)?; - - let mut count = 0; - for edge in graph.edges_directed(segment, self.direction) { - count += 1; - match self.direction { - Direction::Outgoing => { - if edge - .weight() - .src - .is_some_and(|src_cidx| !available_range.contains(&src_cidx)) - { - continue; - } - self.next.push_back((edge.target(), edge.weight().dst)); - } - Direction::Incoming => { - if edge - .weight() - .dst - .is_some_and(|dst_cidx| !available_range.contains(&dst_cidx)) - { - continue; - } - self.next - .push_back((edge.source(), edge.weight().src.map(|cidx| cidx + 1))); - } - } - } - if let Some(leafs) = self.leafs.as_mut().filter(|_| count == 0) { - leafs.push(segment); - } - Some((segment, available_range)) - } - - fn select_range( - &mut self, - graph: &crate::init::PetGraph, - segment: SegmentIndex, - first_commit_index: Option, - ) -> Option> { - match first_commit_index { - None => { - debug_assert!( - graph[segment].commits.is_empty(), - "BUG: we always assure that we set the commit index if a segment has commits: {segment:?}" - ); - if !self.seen_empty_segments.insert(segment) { - return None; - } - Some(0..0) - } - Some(start_commit_idx) => { - let segment = &graph[segment]; - let mut range = match self.direction { - Direction::Outgoing => start_commit_idx..segment.commits.len(), - Direction::Incoming => 0..start_commit_idx, - }; - - // NOTE: this assumes that we always consume all segments from a given starting position, - // which is assured by the lines above that set the initial range. - let num_inserted_from_front = segment - .commits - .get(range.clone())? - .iter() - .take_while(|c| self.seen.insert(c.id)) - .count(); - if num_inserted_from_front == 0 { - let num_inserted_from_back = segment - .commits - .get(range.clone())? - .iter() - .rev() - .take_while(|c| self.seen.insert(c.id)) - .count(); - if num_inserted_from_back == 0 { - return None; - } - range.start = range.end - num_inserted_from_back; - } else { - range.end = range.start + num_inserted_from_front; - } - debug_assert!( - !range.is_empty(), - "BUG: empty ranges should already have been handled, must return None" - ); - Some(range) - } - } - } -} - -#[cfg(test)] -mod tests { - use super::Queue; - - #[test] - fn explicit_exhaustion_does_not_count_as_hard_limit_hit() { - let mut queue = Queue::new_with_limit(Some(1)); - - queue.exhaust(); - - assert!(queue.is_exhausted(), "explicit exhaustion stops queueing"); - assert!( - !queue.record_hard_limit_if_exhausted(), - "hard-limit state stays separate from explicit exhaustion" - ); - assert!( - !queue.hard_limit_hit(), - "explicit exhaustion must not mark the hard limit as hit" - ); - } - - #[test] - fn hard_limit_exhaustion_records_hard_limit_hit() { - let mut queue = Queue::new_with_limit(Some(0)); - - assert!( - queue.record_hard_limit_if_exhausted(), - "a depleted hard limit stops queueing" - ); - assert!( - queue.hard_limit_hit(), - "the queue records when the hard limit stopped queueing" - ); - } -} diff --git a/crates/but-graph/src/init/walk/tests.rs b/crates/but-graph/src/init/walk/tests.rs deleted file mode 100644 index b0478561290..00000000000 --- a/crates/but-graph/src/init/walk/tests.rs +++ /dev/null @@ -1,119 +0,0 @@ -use super::*; - -fn gtt(generation: Option, committer_time: u64) -> GenThenTime { - GenThenTime { - generation, - committer_time, - } -} - -#[test] -fn gen_then_time_total_ordering_is_transitive_with_mixed_generations() { - // This is the exact scenario that previously caused a panic: - // "user-provided comparison function does not correctly implement a total order" - // - // With the old implementation (fall back to time-only when generations are mixed): - // A < B (by time: 200 > 150) - // B < C (by time: 150 > 100) - // A > C (by generation: 5 > 3) โ€” transitivity violation! - let a = gtt(Some(3), 200); - let b = gtt(None, 150); - let c = gtt(Some(5), 100); - - let ab = a.cmp(&b); - let bc = b.cmp(&c); - let ac = a.cmp(&c); - - // With the fix, None is treated as u32::MAX (youngest), so B sorts first. - // B(gen=MAX) > A(gen=3) โ†’ B < A (reversed) - // B(gen=MAX) > C(gen=5) โ†’ B < C (reversed) - // C(gen=5) > A(gen=3) โ†’ C < A (reversed) - // Order: B < C < A โ€” fully transitive. - assert_eq!( - ab, - Ordering::Greater, - "A should sort after B (B has None โ†’ u32::MAX)" - ); - assert_eq!( - bc, - Ordering::Less, - "B should sort before C (B has None โ†’ u32::MAX)" - ); - assert_eq!( - ac, - Ordering::Greater, - "A should sort after C (gen 5 > gen 3)" - ); -} - -#[test] -fn gen_then_time_none_generation_treated_as_youngest() { - // None generation maps to u32::MAX, which reversed sorts first (youngest). - let with_gen = gtt(Some(100), 500); - let without_gen = gtt(None, 500); - assert_eq!( - without_gen.cmp(&with_gen), - Ordering::Less, - "None generation (u32::MAX) should sort before any known generation" - ); -} - -#[test] -fn gen_then_time_both_some_sorts_by_generation_then_time() { - let young_gen = gtt(Some(10), 100); - let old_gen = gtt(Some(2), 200); - assert_eq!( - young_gen.cmp(&old_gen), - Ordering::Less, - "Higher generation sorts first (reversed), regardless of time." - ); - - let recent = gtt(Some(5), 300); - let old = gtt(Some(5), 100); - assert_eq!( - recent.cmp(&old), - Ordering::Less, - "Equal generation falls back to time (higher time sorts first)." - ); -} - -#[test] -fn gen_then_time_both_none_sorts_by_time() { - let recent = gtt(None, 300); - let old = gtt(None, 100); - assert_eq!( - recent.cmp(&old), - Ordering::Less, - "Higher time sorts first (reversed)." - ); - assert_eq!( - gtt(None, 100).cmp(>t(None, 100)), - Ordering::Equal, - "Equal time โ†’ equal." - ); -} - -#[test] -fn gen_then_time_sort_is_deterministic_and_total_issue_12343() { - // Throw a mix of items at sort and ensure it doesn't panic. - // This directly exercises the code path from the stack trace. - let mut items = [ - gtt(Some(3), 200), - gtt(None, 150), - gtt(Some(5), 100), - gtt(None, 300), - gtt(Some(1), 300), - gtt(Some(5), 200), - gtt(None, 100), - gtt(Some(3), 100), - ]; - items.sort(); - - // Verify the result is actually sorted (each element โ‰ค the next). - for window in items.windows(2) { - assert!( - window[0].cmp(&window[1]) != Ordering::Greater, - "Sort result is not ordered: {window:?}" - ); - } -} diff --git a/crates/but-graph/src/lib.rs b/crates/but-graph/src/lib.rs index 685a1aedc0c..57c4470405e 100644 --- a/crates/but-graph/src/lib.rs +++ b/crates/but-graph/src/lib.rs @@ -1,51 +1,46 @@ -//! A graph data structure for seeing the Git commit graph as segments. +//! A graph data structure for seeing the Git commit graph as a workspace. //! -//! This crate is part of the Correctness and Stability initiative. +//! ### The pipeline //! -//! ### Before the Graph +//! Everything in this crate flows through one pipeline with a single substrate, the +//! [`CommitGraph`]: //! -//! The application traditionally displays commits in lanes while allowing them to be segmented. Today a lane is a -//! stack of segments. This is great for users as it's easy to understand, but there is a problem with it: it's a complete lie. -//! -//! To generate stacks, one performed a graph traversal, following only the first parent, down to the merge-base of the workspace -//! commit with the target branch. It's clear how this degenerates information especially around merges that the application -//! helps to create by allowing to merge with the target branch. -//! -//! When trying to rebase the workspace onto updated target branches though, it still exclusively operates in stacks, ignoring -//! the complexities of the underlying graph, and the illusion starts to break down. -//! -//! Besides that, there is an inherent mental issue when programming with data structures that degenerate information, as the -//! resulting program will inherently be unfit for the task as it's based on over-simplified assumptions baked into its very core. -//! -//! With the current data structures, achieving correctness simply isn't possible. -//! -//! ### The Graph - the Solution -//! -//! The graph solves this problem by simplifying the commit-graph and optimizing it for traversal, without oversimplifying the -//! underlying commit-graph. That way, the notion of stacks with segments is merely a view of the graph specifically prepared -//! for presentation. -//! -//! When operations are supposed to be performed, we can now do things like this `git rebase --preserve-merges origin/main`, -//! creating a correctly ordered list of operations to do the transplantation correctly. Thanks to the additional metadata -//! collected into the Graph I'd expect us to be able to whatever it takes without limitation. -//! -//! Besides that, operating on a graph, despite its own complexities, is finally aligning the mental model of the programmer -//! with what's actually there, for algorithms suitable to perform the job correctly. +//! ```text +//! repository โ”€โ”€walkโ”€โ”€โ–ถ CommitGraph (+ ref layout) โ”€โ”€projectโ”€โ”€โ–ถ Workspace +//! โ–ฒ โ”‚ +//! โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ but-rebase edits โ—€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +//! ``` //! -//! All this makes the Graph the **new core data-structure** that is the world of GitButler and upon which visualizations and -//! mutation operations are based. +//! * **walk** ([`walk`], entered through `CommitGraph::from_head` and `CommitGraph::from_tip`): +//! the traversal seeds from `HEAD`, the workspace ref, the target, and the stack branches; +//! obeys goals and limits; propagates flags; and accumulates the [`CommitGraph`] โ€” an arena +//! of commits with ordered parent arrays, the connectivity the traversal actually followed, +//! and every encountered ref attached as data. +//! * **build** (the private `build` module, entered through `graph_from_repository`): +//! gather-then-build. Pure fact and planning passes decide the workspace's ref structure as +//! data โ€” boundaries, chains from workspace metadata, the name lifecycle โ€” authored as +//! build-internal segments whose only lasting outputs are the stored [`ref_layout`] (every +//! surfaced ref with its position over the commit graph) and the context the projection +//! reads (enrichment details, the entry's resolution verdict). +//! * **project** ([`workspace`], entered through the `Workspace::from_*` constructors): the +//! application view โ€” stacks of first-parent chains with integration status against the +//! target โ€” derived from the commit graph, its layout and the build context alone. +//! * **edit** (`but-rebase`): the editor is created FROM the [`CommitGraph`] (mutability follows +//! reachability), and an edited commit graph re-enters the same build via +//! `workspace_from_commit_graph` โ€” edit previews and fresh walks share one code path. +//! `but-workspace` sits on top of both as the operations layer. //! //! ### New Workspace Concepts //! -//! The workspace is merely a projection of *The Graph*, and as such is mostly useful for display and user interaction. -//! In the end it boils down to passing commit-hashes, or [segment-ids](SegmentIndex) at most. +//! The workspace is a projection of the commit graph, and as such is mostly useful for display and user interaction. +//! In the end it boils down to passing commit-hashes. //! //! The workspace has been redesigned from the ground up for flexibility, enabling new user-experiences. To help thinking //! about these, a few new concepts will be good to know about. //! //! #### Entrypoint //! -//! *The Graph* knows where its traversal started as *Entrypoint*, even though it may extend beyond the entrypoint as it +//! The graph knows where its traversal started as *Entrypoint*, even though it may extend beyond the entrypoint as it //! needs to discover possible surrounding workspaces and the target branches that come with them. //! In practice, the entrypoint relates to the position of the Git `HEAD` reference, and with that it relates to what //! the user currently sees in their worktree. @@ -56,14 +51,14 @@ //! This is particularly relevant in open-ended traversals outside of workspaces, they can go on until the end of history, //! literally. //! -//! For that reason, whenever a commit isn't the end of the graph, but the end traversal as a [limit was hit](init::Options::with_limit_hint), +//! For that reason, whenever a commit isn't the end of the graph, but the end traversal as a [limit was hit](walk::Options::with_limit_hint), //! it will be flagged as such. //! //! This way one can visualize such Early Ends, and allow the user to extend the traversal selectively the next time it //! is performed. //! //! Despite that, one has to learn how to deal with possible huge graphs, and possible workspaces with a lot of commits, -//! and [a hard limit](init::Options::with_hard_limit()) as long as downstream cannot deal with this on their own. +//! and [a hard limit](walk::Options::with_hard_limit()) as long as downstream cannot deal with this on their own. //! //! #### Managed Workspaces, and unmanaged ones //! @@ -71,68 +66,52 @@ //! only the case for workspaces that have been created by GitButler. //! //! Workspaces without such metadata can be anything, and are usually just made up to allow GitButler to work with it based -//! on any `HEAD` position. These should be treated with care, and multi-lane workflows should generally be avoided - these +//! on any `HEAD` position. These should be treated with care, and multi-stack workflows should generally be avoided - these //! are reserved to managed Workspaces with the managed merge commit that comes with them. //! //! #### Optional Targets //! //! Even on *Managed Workspaces*, target references are now optional. This makes it possible to have a workspace that doesn't -//! know if it's integrated or not. These are the reason a [soft limit](init::Options::with_limit_hint()) must always be set +//! know if it's integrated or not. These are the reason a [soft limit](walk::Options::with_limit_hint()) must always be set //! to assure the traversal doesn't fetch the entire Git history. //! //! This, however, also means that the workspace creation doesn't have to be interrupted by a "what's your target" prompt anymore. //! Instead, this can be prompted once an action first requires it. //! -//! #### Commit Flags and Segment Flags +//! #### Commit Flags //! -//! For convenience, various boolean parameters have been aggregated into [bitflags](Commit::flags). Thanks to the way *The Graph* -//! is traversed, we know that the first commit of any [graph segment](Segment) will always bear the flags that are also used by every other commit -//! contained within it. Thus, [segment flags](Segment::non_empty_flags_of_first_commit()) are equivalent to the flags of -//! their first commit. -//! -//! The same is *not* true for [stack segments](workspace::StackSegment), i.e. segments within a [workspace projection](Workspace). -//! The reason for this is that they are first-parent aggregations of one *or more* [graph segments](Segment), and thus have multiple -//! sets of flags, possibly one per [segment](Segment). +//! For convenience, various boolean parameters have been aggregated into [bitflags](Commit::flags), +//! propagated per commit during traversal. [Stack segments](workspace::StackSegment) aggregate +//! first-parent runs of commits, so a segment's commits may carry multiple distinct flag sets โ€” +//! read flags off commits, not segments. //! //! #### The 'frozen' Commit-Flag //! -//! Commits now have a new state that tells for each if it is reachable by *any* remote, and further, if it's reachable -//! by the remote configured for *their segment*. -//! -//! This additional partitioning could be leveraged for enhanced user experiences. +//! [`CommitFlags::NotInRemote`] marks commits NOT reachable from any remote-seeded tip. The +//! workspace projection inverts it into +//! [`StackCommitFlags::ReachableByRemote`](workspace::StackCommitFlags): commits others may +//! already have observed, to be treated as frozen and not manipulated casually. //! -//! ### The Graph - Traversal and more -//! -//! There are three distinct steps to processing the git commit-graph into more usable forms. -//! -//! * **traversal** -//! - walk the git commit graph to produce a segmented graph, which assigns commits to segments, -//! but also splits segments on incoming and multiple outgoing connections. -//! * **reconciliation** -//! - a post-processing step which adds workspace metadata into the segmented graph, as such information -//! can't be held in the commit-graph itself. -//! * **projection** -//! - transform the segmented and reconciled graph into a view that is application-specific, i.e. see -//! stacks of first-parent traversed named segments. +//! ### Build decisions //! //! #### Commits are owned by Segments //! //! A commit can only be owned by a single segment. Thus, there are empty *named* segments which point at other segments, //! effectively representing a reference. -//! Which of these references gets to own a commit depends on the traversal logic, or can be the result of *Reconciliation*. +//! Which of these references gets to own a commit is a *planning* decision. //! -//! #### Reconciliation +//! #### Planning chains from metadata //! -//! *The Graph* is created from traversing the Git commit graph. Thus, information that is not contained in it has to be -//! reconciled with *what was actually traversed*. +//! The graph is created from traversing the Git commit graph. Thus, information that is not contained in it, +//! like workspace metadata, has to shape the segmented graph as it is built. //! -//! Nonetheless, we can create *stacks* as independent branches and dependent branches inside of them without having +//! That way, we can create *stacks* as independent branches and dependent branches inside of them without having //! a single commit to differentiate their respective branches from each other. //! //! Imagine a repository with a single commit `73a30f8` with the following Git references pointing to it: `gitbutler/workspace`, //! `stack1-segment1`, `stack1-segment2`, `stack2-segment1`, and `refs/remotes/origin/main`. //! -//! Right after traversal, a Graph would look like this: +//! A naive segmentation of the traversal would look like this: //! //! ```text //! โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” @@ -150,14 +129,12 @@ //! โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ //! ``` //! -//! This is due to `gitbutler/workspace` finding `73a30f8` first, with `origin/main` arriving later, pointing to the -//! first commit in `gitbutler/workspace` effectively. The other references aren't participating in the traversal. +//! This is because `gitbutler/workspace` owns `73a30f8`, with `origin/main` merely pointing to +//! that commit; the other references would be plain refs on it. //! -//! The tip that finds the commit first is dependent on various factors, and it could also happen that `origin/main` finds -//! it first. In any case, this needs to be adjusted after traversal in the process called *reconciliation*, so the graph -//! matches what our [workspace metadata](but_core::ref_metadata::Workspace::stacks) says it should be. -//! -//! After reconciling, the graph would become this: +//! The chain plan instead reads [workspace metadata](but_core::ref_metadata::Workspace::stacks) before any +//! segment exists and decides which refs form chains of empty segments. Materialization then mints this +//! shape directly: //! //! ```text //! โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” @@ -191,241 +168,38 @@ //! //! #### Projection //! -//! A projection is a mapping of the segmented graph to any shape an application needs, and for any purpose. -//! It cannot be stressed enough that the source of truth for all commit-graph manipulation must be the segmented graph, -//! as projections are inherently lossy. -//! Thus, it's useful create projects with links back to the segments that the information was extracted from. +//! A projection maps the commit graph and its stored ref layout to any shape an application +//! needs. Projections are inherently lossy and speak in commit ids and ref names โ€” manipulation +//! never operates on a projection: the rebase editor edits the [`CommitGraph`], and re-projecting +//! the edited substrate yields the next [`Workspace`]. #![forbid(unsafe_code)] #![deny(missing_docs)] -mod segment; -/// Use this for basic types like [`petgraph::Direction`], and graph algorithms. -pub use petgraph; -pub use segment::{ - Commit, CommitFlags, RefInfo, Segment, SegmentFlags, SegmentMetadata, StopCondition, Worktree, - WorktreeKind, +mod records; +pub use records::{ + Commit, CommitFlags, RefInfo, SegmentMetadata, StopCondition, Worktree, WorktreeKind, }; -mod api; -pub use api::FirstParent; +boolean_enums::gen_boolean_enum!(pub FirstParent); /// Produce a graph from a Git repository. -pub mod init; +pub mod walk; #[path = "projection/mod.rs"] pub mod workspace; -pub use workspace::workspace::Workspace; - -mod utils; - -mod statistics; -pub use statistics::Statistics; - -mod debug; - -/// 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. -pub type CommitIndex = usize; - -/// A graph of connected segments that represent a section of the actual commit-graph. -#[derive(Default, Debug, Clone)] -#[must_use] -pub struct Graph { - inner: init::PetGraph, - /// From where the graph was created. This is useful if one wants to focus on a subset of the graph. - /// - /// This is `None` only for a freshly default-initialized graph, or while a graph is being assembled before - /// the first segment is inserted. Graphs returned by the traversal constructors are expected to have an - /// entrypoint, even for unborn refs; in that case the segment is present and the commit is - /// [`EntryPointCommit::Unborn`]. - /// - /// The second value is the traversal tip, if there is one. - /// - /// Post-processing may move the entrypoint to an empty synthetic segment, for example a named workspace ref - /// without a workspace commit. The segment still marks the ref's place in the graph, but it has no local - /// commit slot that can hold the ref target. In that case the commit id is kept here so - /// [`Graph::redo_traversal_with_overlay()`] can keep using the original traversal tip if the ref can no - /// longer be resolved in the overlay/repository. - entrypoint: Option<(SegmentIndex, EntryPointCommit)>, - /// The ref_name used when starting the graph traversal. It is set to help assure that the entrypoint stays - /// on the correctly named segment, knowing that the post-process may alter segments quite substantially - /// when crating independent and dependent branches. - entrypoint_ref: Option, - /// Effective initial traversal tips, in segment creation order. - /// - /// These are the caller-provided tips plus option-derived tips after - /// validation, deduplication, and queue-order normalization. Segment ids are - /// intentionally not stored here: post-processing can split, delete, and - /// reconnect segments, so consumers re-resolve each tip by commit id against - /// the final graph shape and filter for the roles they need. - traversal_tips: Vec, - /// Ad-hoc/single-branch local branch orders read from metadata while post-processing. - /// - /// These are kept on the graph so projection can tell the difference between - /// an ordinary ad-hoc lower-bound branch and the bottom branch of a - /// GitButler-created dependent branch chain. - ad_hoc_branch_stack_orders: Vec>, - /// It's `true` only if we have stopped the traversal due to a hard limit. - hard_limit_hit: bool, - /// The options used to create the graph, which allows it to regenerate itself after something - /// possibly changed. This can also be used to simulate changes by injecting would-be information. - /// Public to be able to change it before calling [Graph::redo_traversal_with_overlay()]. - pub options: init::Options, - /// Project-wide metadata used for target ref, target commit, and push remote resolution. - pub project_meta: but_core::ref_metadata::ProjectMeta, - /// All remote names that aren't URLs and that were retrieved during the traversal. - /// - /// They are useful to extract remote names from remote tracking refs like `refs/remotes/origin/master`, - /// which may have slashes in them. - pub symbolic_remote_names: Vec, -} - -#[derive(Debug, Clone, Copy)] -pub(crate) enum EntryPointCommit { - /// The traversal tip is known. - /// - /// This is an object id rather than a synthetic index because an empty entrypoint segment has no commit slot - /// to index into, nor is it reliably reachable by traversing the graph. If the commit is present in the - /// entrypoint segment, its [`CommitIndex`] can be derived from the segment and this id. - /// This happens when a workspace reference doesn't have an (unneeded) workspace merge commit, - /// and is connected to one or more named empty segments which themselves only point to the workspace base. - /// Then from Git's point of view, all involved refs point to the workspace base, but for use it's - /// already a graph, and one that isn't representable even with symbolic refs as the workspace ref segment - /// can easily point to multiple refs at the same time. - AtCommit(gix::ObjectId), - /// The traversal started from an unborn ref and has no tip commit. - Unborn, -} - -impl EntryPointCommit { - fn index_in(self, segment: &Segment) -> Option { - match self { - EntryPointCommit::AtCommit(id) => segment.commit_index_of(id), - EntryPointCommit::Unborn => None, - } - } - - pub(crate) fn object_id(self) -> Option { - match self { - EntryPointCommit::AtCommit(id) => Some(id), - EntryPointCommit::Unborn => None, - } - } -} - -impl Graph { - /// Return the entrypoint as a segment plus the optional position of its tip commit in that segment. - /// - /// The graph stores the tip as an object id so it survives post-processing that moves the entrypoint to - /// an empty or otherwise synthetic segment. Some graph-local consumers, like debug rendering and - /// statistics, still need the segment-relative commit position to mark where the entrypoint appears in - /// the current graph shape, so it is derived here instead of being stored as mutable state. - fn entrypoint_location(&self) -> Option<(SegmentIndex, Option)> { - let (segment_index, commit) = self.entrypoint?; - let segment = self.inner.node_weight(segment_index)?; - Some((segment_index, commit.index_in(segment))) - } -} - -/// A resolved entry point into the graph for easy access to the entrypoint segment and, -/// if present, the commit that started traversal along with the segment that owns it. -#[derive(Debug, Copy, Clone)] -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 traversal and the segment that owns it. - /// - /// This can differ from [`Self::segment`] when post-processing moved the entrypoint to an - /// empty synthetic segment, such as a virtual workspace tip segment. - /// - /// May be `None` if the entrypoint was a reference in a newly born repository, - /// which doesn't have any commits. - pub commit_and_owner: Option<(&'graph Commit, &'graph Segment)>, -} - -impl<'graph> EntryPoint<'graph> { - /// The commit that started traversal, if the entrypoint is not unborn. - pub fn commit(&self) -> Option<&'graph Commit> { - self.commit_and_owner.map(|(c, _)| c) - } -} - -/// Relationship of one segment to another in terms of graph ancestry. -#[derive(Debug, Copy, Clone, Eq, PartialEq)] -pub enum SegmentRelation { - /// Both segment indices point to the same segment. - Identity, - /// The first segment is an ancestor of the second segment. - Ancestor, - /// The first segment is a descendant of the second segment. - Descendant, - /// Segments share history, but neither is ancestor of the other. - Diverged, - /// Segments do not share any history. - Disjoint, -} - -/// This structure is used as data associated with each edge and is mainly for collecting -/// the intent of an edge, which should always represent the connection of a commit to another. -/// Sometimes, it represents the connection from a commit (or segment) to an empty segment which -/// doesn't yet have a commit. -/// The idea is to write code that keeps edge information consistent, and our visualization tools highlights -/// issues with the inherent invariants. -#[derive(Debug, Copy, Clone)] -pub struct Edge { - /// `None` if the source segment has no commit. - src: Option, - /// The commit id at `src` in the segment commit list. - src_id: Option, - dst: Option, - /// The commit id at `dst` in the segment commit list. - dst_id: Option, - /// The position of this edge's destination among the source commit's parents. - /// For a merge commit with parents `[A, B]`, the edge to `A` has `parent_order = 0` - /// and the edge to `B` has `parent_order = 1`. - /// Deliberately not `usize`: `Edge` is stored in every petgraph edge slot, - /// and widening this field pushes hot edge storage over an important size boundary. - /// - /// This limit would only be reached if there is a merge with 4.3 billion commits. - pub(crate) parent_order: u32, -} - -impl Edge { - /// Return the 0-based position of this edge's destination among the source commit's parents. - /// For instance, if the source is a merge commit and this edge represents the connection - /// to the second parent, the output will be `Some(1)`. - /// - /// This is `None` when the edge does not point at a concrete destination commit. - /// That happens for synthetic graph structure such as empty virtual segments, - /// where there is no Git commit parent for this edge to identify. - pub fn parent_order(&self) -> Option { - self.dst_id.map(|_| self.parent_order) - } - - /// Useful when reusing an edge to assure it doesn't list commits that don't exist in `src_idx` and `dst_idx` anymore. - pub(crate) fn adjusted_for( - mut self, - src_sidx: SegmentIndex, - dst_sidx: SegmentIndex, - graph: &init::PetGraph, - ) -> Self { - let commits = &graph[src_sidx].commits; - let (id, idx) = commits - .last() - .map(|c| (Some(c.id), Some(commits.len() - 1))) - .unwrap_or_default(); - self.src_id = id; - self.src = idx; - - let commits = &graph[dst_sidx].commits; - let (id, idx) = commits - .first() - .map(|c| (Some(c.id), Some(0))) - .unwrap_or_default(); - self.dst_id = id; - self.dst = idx; - - self - } -} -/// An index into the [`Graph`]. -pub type SegmentIndex = petgraph::graph::NodeIndex; +pub use workspace::Workspace; + +/// The commit-first graph flattened out of the raw traversal โ€” the substrate every graph build +/// starts from. See the module docs. +mod commit_graph; +mod commit_graph_diagnostics; +pub use commit_graph::CommitGraph; +pub use commit_graph_diagnostics::CommitGraphStatistics; +/// The graph builders: derive the workspace's ref layout and context from a [`CommitGraph`]. See the module docs. +mod build; +/// The metadata-driven ref placement table stored on the commit graph. See the module docs. +pub mod ref_layout; +pub(crate) use build::graph_from_repository; +pub(crate) use build::workspace_from_commit_graph; +pub(crate) use build::{graph_from_repository_seeds, graph_from_repository_unmanaged}; + +pub(crate) mod debug; diff --git a/crates/but-graph/src/projection/workspace/api/legacy.rs b/crates/but-graph/src/projection/api/legacy.rs similarity index 62% rename from crates/but-graph/src/projection/workspace/api/legacy.rs rename to crates/but-graph/src/projection/api/legacy.rs index ea7d78cc21b..863893a4501 100644 --- a/crates/but-graph/src/projection/workspace/api/legacy.rs +++ b/crates/but-graph/src/projection/api/legacy.rs @@ -47,7 +47,7 @@ impl Workspace { /// This isn't to say it's wrong, but it certainly doesn't appear the most logical. /// Could be an issue with terminology, what is `Head` anyway? Is it a branch, Stack, /// branch in a stack? - /// Terms used here are Tip for the commit-id of the start of something. + /// Terms used here are Seed for the commit-id of the start of something. /// /// Also, we can easily write what's needed to serve the needs of new code, so it's /// also fine if this goes away. @@ -70,9 +70,9 @@ impl Workspace { .filter_map(|stack| stack.tip_skip_empty()) .collect::>(); if heads.is_empty() - && let Some(entrypoint_commit) = self.graph.entrypoint()?.commit() + && let Some(entrypoint_commit_id) = self.entrypoint_commit_id()? { - heads.push(entrypoint_commit.id); + heads.push(entrypoint_commit_id); } let target_ref_id = repo.find_reference(target_ref)?.id(); @@ -91,48 +91,4 @@ impl Workspace { Ok(out) } - /// Return the target reference name if this workspace has a branch-backed target. - /// - /// ## Before Promoting this to non-legacy - /// - /// To me this looks like an 'unsure' way of getting the target-ref name. - /// I'd trust that `but-graph` knows how to 'see' the target ref during traversal - /// so the `target_ref` field is populated. I would *not* read it from `self.metadata`, - /// which means it might also not exist at all. - /// If promoted as is, these exact semantics should be documented, along with its intended use. - /// - /// Use [Self::target_ref_name()] instead. - pub fn legacy_target_ref_name(&self) -> Option<&gix::refs::FullNameRef> { - self.target_ref - .as_ref() - .map(|target| target.ref_name.as_ref()) - .or_else(|| { - self.graph - .project_meta - .target_ref - .as_ref() - .map(|name| name.as_ref()) - }) - } - - /// Return the remembered target commit id that anchors this workspace to its target. - /// - /// This is the projection equivalent of the legacy `Target::sha` field. It intentionally - /// differs from [`Self::target_ref_tip_commit_id()`], which returns the current tip of the target - /// branch. - /// - /// ## Before Promoting this to non-legacy - /// - /// I'd expect this to not be useful unless maybe for display purposes. - /// What I don't like about this function is that it resorts prefers `metadata` over - /// the resolved and validated `target_commit` on this instance, without making clear why - /// in the docs. - /// I think it's important to nail this semantically, and if in doubt, I'd rather make `metadata` - /// inaccessible to provide only a single-source of truth and remove ambiguity. - pub fn target_base_commit_id(&self) -> Option { - self.graph - .project_meta - .target_commit_id - .or_else(|| self.target_commit.as_ref().map(|target| target.commit_id)) - } } diff --git a/crates/but-graph/src/projection/workspace/api/mod.rs b/crates/but-graph/src/projection/api/mod.rs similarity index 66% rename from crates/but-graph/src/projection/workspace/api/mod.rs rename to crates/but-graph/src/projection/api/mod.rs index 3c560beebdf..ebd74dfb70c 100644 --- a/crates/but-graph/src/projection/workspace/api/mod.rs +++ b/crates/but-graph/src/projection/api/mod.rs @@ -3,21 +3,21 @@ use std::borrow::Cow; use anyhow::Context; use bstr::BStr; use but_core::{RefMetadata, extract_remote_name_and_short_name, ref_metadata::StackId}; -use petgraph::Direction; use tracing::instrument; use crate::{ - CommitFlags, CommitIndex, Graph, Segment, SegmentIndex, Workspace, + Workspace, workspace::{ - Stack, StackCommit, StackSegment, TargetRef, WorkspaceKind, - workspace::find_segment_owner_indexes_by_refname, + Stack, StackCommit, StackSegment, WorkspaceKind, find_segment_owner_indexes_by_refname, }, }; -/// A utility type to represent `(stack_idx, segment_idx, commit_idx)`. -pub type CommitOwnerIndexes = (usize, usize, CommitIndex); +/// A utility type to represent `(stack_idx, segment_idx, commit_idx)`, indexing one commit +/// as `ws.stacks[stack_idx].segments[segment_idx].commits[commit_idx]`. +pub type CommitOwnerIndexes = (usize, usize, usize); mod queries; +pub use queries::StackTip; #[cfg(feature = "legacy")] pub use queries::legacy::HeadStatus; @@ -28,7 +28,7 @@ impl Workspace { /// This is useful to make this instance represent changes to `repo` or `meta`. /// /// Pass a freshly read `project_meta` to pick up target changes as well, or - /// `self.graph.project_meta.clone()` to deliberately keep the current one, + /// `self.ctx.project_meta.clone()` to deliberately keep the current one, /// e.g. in the middle of an operation. #[instrument( name = "Workspace::refresh_from_head", @@ -42,10 +42,77 @@ impl Workspace { meta: &impl RefMetadata, project_meta: but_core::ref_metadata::ProjectMeta, ) -> anyhow::Result<()> { - let graph = Graph::from_head(repo, meta, project_meta, self.graph.options.clone())?; - *self = graph.into_workspace()?; + *self = Workspace::from_head(repo, meta, project_meta, self.ctx.options.clone())?; Ok(()) } + + /// Refresh this instance by projecting `commit_graph` directly โ€” typically the rebase + /// editor's mutated arena, which IS the next workspace, so no repository rewalk is + /// needed โ€” mutate-then-project and rewalk-then-project are equivalent. + /// + /// Falls back to a rewalk when the commit graph has nothing to project: HEAD is unborn + /// (e.g. its referent was deleted without a repoint) or points outside the graph. + #[instrument( + name = "Workspace::refresh_from_commit_graph", + level = "debug", + skip_all, + err(Debug) + )] + pub fn refresh_from_commit_graph( + &mut self, + commit_graph: crate::CommitGraph, + repo: &gix::Repository, + meta: &impl RefMetadata, + ) -> anyhow::Result<()> { + let project_meta = self.ctx.project_meta.clone(); + let options = self.ctx.options.clone(); + let Some(mutated) = crate::workspace_from_commit_graph( + commit_graph, + repo, + meta, + project_meta.clone(), + options, + crate::walk::Overlay::default(), + )? + else { + return self.refresh_from_head(repo, meta, project_meta); + }; + *self = mutated; + Ok(()) + } + + /// Preview `commit_graph` โ€” typically a not-yet-materialized rebase's mutated arena, which + /// already IS the next workspace state โ€” with the rebase's pending ref edits and entrypoint + /// served from `overlay`; no repository rewalk. The materialized twin is + /// [`Self::refresh_from_commit_graph`]. + /// + /// Falls back to an overlay rewalk ([`Self::redo`]) when the commit graph has nothing to + /// project: the entrypoint is unborn or points outside the graph. + #[instrument( + name = "Workspace::preview_from_commit_graph", + level = "debug", + skip_all, + err(Debug) + )] + pub fn preview_from_commit_graph( + &self, + commit_graph: crate::CommitGraph, + repo: &gix::Repository, + meta: &impl RefMetadata, + overlay: crate::walk::Overlay, + ) -> anyhow::Result { + match crate::workspace_from_commit_graph( + commit_graph, + repo, + meta, + self.ctx.project_meta.clone(), + self.ctx.options.clone(), + overlay.clone(), + )? { + Some(preview) => Ok(preview), + None => self.redo(repo, meta, overlay), + } + } } /// Query @@ -59,12 +126,7 @@ impl Workspace { /// Return the name of the workspace reference by looking our segment up in `graph`. /// Note that for managed workspaces, this can be retrieved via [`WorkspaceKind::Managed`]. pub fn ref_name(&self) -> Option<&gix::refs::FullNameRef> { - self.graph[self.id].ref_name() - } - - /// Like [Self::ref_name()], but returns reference and worktree information instead. - pub fn ref_info(&self) -> Option<&crate::RefInfo> { - self.graph[self.id].ref_info.as_ref() + self.tip_ref_info.as_ref().map(|ri| ri.ref_name.as_ref()) } /// Like [`Self::ref_name()`], but return a generic `` name for unnamed workspaces. @@ -87,7 +149,7 @@ impl Workspace { if let Some(tr) = self.target_ref.as_ref() { // TODO: should we rather get remote configuration from the repository? let remote_names = self - .graph + .ctx .symbolic_remote_names .iter() .map(|name| Cow::Borrowed(name.as_str().into())) @@ -95,7 +157,7 @@ impl Workspace { extract_remote_name_and_short_name(tr.ref_name.as_ref(), &remote_names) .map(|(remote_name, _)| remote_name) } else { - self.graph.project_meta.push_remote.clone() + self.ctx.project_meta.push_remote.clone() } } @@ -103,22 +165,19 @@ impl Workspace { /// /// Prefers the stored [`Self::target_commit`] (the last-synced target SHA), /// falling back to the tip of [`Self::target_ref`] (the remote tracking branch). - /// Does not consider additional traversal tips. + /// Does not consider additional traversal seeds. /// /// Use [`Self::stored_target_commit_id()`] instead when callers need only the explicit /// stored target commit without falling back to the target ref tip. /// /// Returns `None` if neither `target_commit` nor `target_ref` is configured. pub fn resolved_target_commit_id(&self) -> Option { - self.stored_target_commit_id().or_else(|| { - self.target_ref - .as_ref() - .and_then(|t| self.tip_commit_by_segment_id(t.segment_index).map(|c| c.id)) - }) + self.stored_target_commit_id().or(self.target_ref_commit_id) } /// Return the `(merge-base, target-commit-id)` of the merge-base between the `commit_to_merge` - /// and the effective target side, see [Self::effective_target_segment_index()]. + /// and the effective target side (target ref, then stored target commit, then the first + /// integrated traversal tip). /// Return `None` when none of these is set, or if there was no merge-base. /// /// Use this to get the merge-base for test-merges between `commit_to_merge` and the target, @@ -128,25 +187,11 @@ impl Workspace { commit_to_merge: impl Into, ) -> Option<(gix::ObjectId, gix::ObjectId)> { let commit_to_merge = commit_to_merge.into(); - let commit_segment_index = self.graph.node_weights().find_map(|s| { - s.commits - .first() - .is_some_and(|c| c.id == commit_to_merge) - .then_some(s.id) - })?; - - let target_segment_index = self.effective_target_segment_index()?; - - let merge_base_segment_index = self - .graph - .find_merge_base(commit_segment_index, target_segment_index)?; - - self.tip_commit_by_segment_id(merge_base_segment_index) - .map(|c| c.id) - .zip( - self.tip_commit_by_segment_id(target_segment_index) - .map(|c| c.id), - ) + let cg = self.commit_graph(); + cg.node(commit_to_merge)?; + let target_commit_id = self.effective_target_commit_id()?; + let merge_base = cg.merge_base(commit_to_merge, target_commit_id)?; + Some((merge_base, target_commit_id)) } /// Return `true` if the workspace itself is where `HEAD` is pointing to. @@ -157,32 +202,16 @@ impl Workspace { .all(|s| s.segments.iter().all(|s| !s.is_entrypoint)) } - /// Return an iterator over all commits in the workspace, - /// i.e. all commits in all segments in all stacks. - /// - /// This doesn't include the workspace commit. - pub fn commits(&self) -> impl Iterator + '_ { - self.stacks - .iter() - .flat_map(|s| s.segments.iter()) - .flat_map(|s| s.commits.iter()) - } - /// Return `true` if the branch with `name` is the workspace target or the targets local tracking branch. - pub fn is_branch_the_target_or_its_local_tracking_branch( - &self, - name: &gix::refs::FullNameRef, - ) -> bool { + pub fn is_target_or_its_local_tracking(&self, name: &gix::refs::FullNameRef) -> bool { let Some(t) = self.target_ref.as_ref() else { return false; }; t.ref_name.as_ref() == name || self - .graph - .lookup_sibling_segment(t.segment_index) - .and_then(|local_tracking_segment| local_tracking_segment.ref_name()) - .is_some_and(|local_tracking_ref| local_tracking_ref == name) + .local_tracking_branch(t.ref_name.as_ref()) + .is_some_and(|local_tracking_ref| local_tracking_ref.as_ref() == name) } /// Lookup a triple obtained by [`Self::find_owner_indexes_by_commit_id()`] or panic. @@ -300,13 +329,9 @@ impl Workspace { &self, name: &gix::refs::FullNameRef, ) -> Option<(&Stack, &StackSegment)> { - self.stacks.iter().find_map(|stack| { - stack.segments.iter().find_map(|seg| { - seg.ref_name() - .is_some_and(|rn| rn == name) - .then_some((stack, seg)) - }) - }) + let (stack_idx, seg_idx) = find_segment_owner_indexes_by_refname(&self.stacks, name)?; + let stack = &self.stacks[stack_idx]; + Some((stack, &stack.segments[seg_idx])) } /// Try to find a commit in the workspace and return it along with the segment and stack containing it. @@ -314,39 +339,10 @@ impl Workspace { &self, commit_id: gix::ObjectId, ) -> Option<(&Stack, &StackSegment, &StackCommit)> { - self.stacks.iter().find_map(|stack| { - stack.segments.iter().find_map(|seg| { - seg.commits - .iter() - .find(|commit| commit.id == commit_id) - .map(|commit| (stack, seg, commit)) - }) - }) - } - - /// Try to find the owning graph segment of `commit_id` in the workspace. - /// - /// This uses the stack segment's `commits_by_segment` offsets to map a projected - /// commit back to its source graph segment. - pub fn find_commit_segment_index(&self, commit_id: gix::ObjectId) -> Option { - let (stack_segment, commit_offset) = self.stacks.iter().find_map(|stack| { - stack.segments.iter().find_map(|seg| { - seg.commits - .iter() - .enumerate() - .find_map(|(offset, commit)| (commit.id == commit_id).then_some((seg, offset))) - }) - })?; - - let mut owning_segment = stack_segment.id; - for (segment_id, offset) in &stack_segment.commits_by_segment { - if *offset > commit_offset { - break; - } - owning_segment = *segment_id; - } - - Some(owning_segment) + let (stack_idx, seg_idx, commit_idx) = self.find_owner_indexes_by_commit_id(commit_id)?; + let stack = &self.stacks[stack_idx]; + let seg = &stack.segments[seg_idx]; + Some((stack, seg, &seg.commits[commit_idx])) } /// Like [`Self::find_segment_and_stack_by_refname`], but fails with an error. @@ -368,32 +364,23 @@ impl Workspace { impl Workspace { /// Produce a distinct and compressed debug string to show at a glance what the workspace is about. pub fn debug_string(&self) -> String { - let graph = &self.graph; + let ref_debug_string = |ref_name: &gix::refs::FullNameRef, + worktree: Option<&crate::Worktree>| { + crate::debug::ref_debug_string_inner(ref_name, worktree, self.has_multiple_worktrees) + }; let (name, sign) = match &self.kind { WorkspaceKind::Managed { ref_info } => ( - graph.ref_debug_string_with_graph_context( - ref_info.ref_name.as_ref(), - ref_info.worktree.as_ref(), - ), + ref_debug_string(ref_info.ref_name.as_ref(), ref_info.worktree.as_ref()), "๐Ÿ˜๏ธ", ), WorkspaceKind::ManagedMissingWorkspaceCommit { ref_info } => ( - graph.ref_debug_string_with_graph_context( - ref_info.ref_name.as_ref(), - ref_info.worktree.as_ref(), - ), + ref_debug_string(ref_info.ref_name.as_ref(), ref_info.worktree.as_ref()), "๐Ÿ˜๏ธโš ๏ธ", ), WorkspaceKind::AdHoc => ( - graph[self.id] - .ref_info - .as_ref() - .map_or("DETACHED".into(), |ri| { - graph.ref_debug_string_with_graph_context( - ri.ref_name.as_ref(), - ri.worktree.as_ref(), - ) - }), + self.tip_ref_info.as_ref().map_or("DETACHED".into(), |ri| { + ref_debug_string(ri.ref_name.as_ref(), ri.worktree.as_ref()) + }), "โŒ‚", ), }; @@ -422,40 +409,9 @@ impl Workspace { } } -/// Utilities -impl TargetRef { - /// Visit all segments whose commits would be considered 'upstream', or part of the target branch - /// whose tip is identified with `target_segment`. The `lower_bound_segment_and_generation` is another way - /// to stop the traversal. - pub(crate) fn visit_upstream_commits( - graph: &Graph, - target_segment: SegmentIndex, - lower_bound_segment_and_generation: Option<(SegmentIndex, usize)>, - mut visit: impl FnMut(&Segment), - ) { - graph.visit_all_segments_including_start_until(target_segment, Direction::Outgoing, |s| { - let prune = true; - if lower_bound_segment_and_generation.is_some_and( - |(lower_bound, lower_bound_generation)| { - s.id == lower_bound || s.generation > lower_bound_generation - }, - ) || s - .commits - .iter() - .any(|c| c.flags.contains(CommitFlags::InWorkspace)) - { - return prune; - } - visit(s); - !prune - }); - } -} - impl std::fmt::Debug for Workspace { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct(&format!("Workspace({})", self.debug_string())) - .field("id", &self.id.index()) .field("kind", &self.kind) .field("stacks", &self.stacks) .field("metadata", &self.metadata) diff --git a/crates/but-graph/src/projection/mod.rs b/crates/but-graph/src/projection/mod.rs index 5804e49847f..37a78360c5b 100644 --- a/crates/but-graph/src/projection/mod.rs +++ b/crates/but-graph/src/projection/mod.rs @@ -1,22 +1,28 @@ -//! A way to represent the graph in a simplified (but more usable) form. +//! The workspace projection: GitButler's view of the world, derived from the +//! [`CommitGraph`](crate::CommitGraph), its stored ref layout and the build context +//! alone โ€” cheap to (re)compute, and carrying the substrate it was derived from. //! -//! This is the current default way of GitButler to perceive its world, but most inexpensively generated to stay -//! close to the source of truth, [The Graph](crate::Graph). -//! -//! These types are not for direct consumption, but should be processed further for consumption by the user. +//! These types simplify aggressively for display and interaction; they are not for +//! direct end-user consumption, but for the operations and UI layers above. /// Types related to the stack representation for graphs. /// -/// Note that these are always a simplification, degenerating information, while maintaining a link back to the graph. +/// These types deliberately simplify โ€” detail is dropped, but a link back to the graph is kept. mod stack; pub use stack::{Stack, StackCommit, StackCommitDebugFlags, StackCommitFlags, StackSegment}; -#[expect(clippy::module_inception)] -pub(crate) mod workspace; -pub use workspace::{TargetCommit, TargetRef, WorkspaceKind}; +pub(crate) mod api; +mod collect; +mod frame; +mod init; +pub use api::StackTip; +pub(crate) use init::project_workspace; #[cfg(feature = "legacy")] -pub use workspace::api::HeadStatus; +pub use api::HeadStatus; + +use anyhow::Context as _; +use but_core::ref_metadata; /// utilities for workspace-related commits. pub mod commit { @@ -34,3 +40,432 @@ pub mod commit { title == GITBUTLER_INTEGRATION_COMMIT_TITLE || title == GITBUTLER_WORKSPACE_COMMIT_TITLE } } + +/// The workspace ref and its metadata, as the builder captured them. +#[derive(Debug, Clone)] +pub(crate) struct WorkspaceMeta { + pub ref_name: gix::refs::FullName, + pub metadata: but_core::ref_metadata::Workspace, +} + +/// The build-time context carried alongside the graph store: everything projection and +/// re-traversal need that isn't commits, segments, or their connections. Produced by the +/// builders, owned by the [`Workspace`]. +#[derive(Default, Clone, Debug)] +pub(crate) struct GraphContext { + /// The options used to create the graph, which allow it to be regenerated ([`Workspace::redo`]) + /// and to simulate changes by injecting would-be information. + pub options: crate::walk::Options, + /// Project-wide metadata used for target ref, target commit, and push remote resolution. + pub project_meta: but_core::ref_metadata::ProjectMeta, + /// All remote names that aren't URLs, as the builder derived them from the repository. + /// + /// They are useful to extract remote names from remote tracking refs like + /// `refs/remotes/origin/master`, which may have slashes in them. + pub symbolic_remote_names: Vec, + /// The local โ†’ remote tracking-branch relationships the builder derived from the repository + /// (git-configured bindings plus name-deduction against the workspace's symbolic remotes). + pub remote_tracking: std::collections::HashMap, + /// The validated snapshot of [`branch_stack_order`](but_core::RefMetadata::branch_stack_order) + /// for the entrypoint ref: the tip-to-base branch chains GitButler created in + /// ad-hoc/single-branch mode, filtered at build time to refs that still exist. + pub ad_hoc_branch_stack_orders: Vec>, + /// Name-keyed enrichment the builder captured for every named branch: its + /// metadata sideband and worktree ownership, for the projection to attach. + pub branch_details: std::collections::HashMap, + /// The workspace ref and its metadata, when the graph contains one โ€” the + /// builder saw it as a segment with workspace metadata. + pub workspace_meta: Option, + /// The commit the entrypoint UNAMBIGUOUSLY points to, resolved through its + /// empty chain at build time โ€” `None` when the chain forks or dead-ends. + pub entry_resolved_commit: Option, +} + +/// The per-branch enrichment the projection attaches by name. +#[derive(Debug, Clone, Default)] +pub(crate) struct BranchDetails { + /// The branch's metadata sideband, if any. + pub metadata: Option, + /// The worktree this branch is checked out in, if any. + pub worktree: Option, + /// Where this branch's LINKED remote tracking branch rests โ€” the walk start + /// for remote reachability. Deliberately absent for ambiguous remotes the + /// builder chose not to link. + pub remote_walk_tip: Option, +} + +/// The traversal entrypoint as captured by the projection: where `HEAD` pointed when the +/// graph was built, and how it relates to the workspace tip. +#[derive(Clone, Debug)] +pub(crate) struct EntrypointInfo { + /// The commit the entrypoint sits on; `None` for unborn refs. + pub commit_id: Option, + /// The name of the entrypoint segment, if any โ€” the re-traversal seed ref. + pub ref_name: Option, + /// Whether the entrypoint segment is the workspace tip segment itself. + pub is_ws_tip: bool, +} + +/// A workspace reference is a list of [Stacks](Stack) projected over a [`CommitGraph`](crate::CommitGraph). +#[derive(Clone)] +pub struct Workspace { + /// Specify what kind of workspace this is. + pub kind: WorkspaceKind, + /// One or more stacks that live in the workspace, in order of parents of the workspace commit if there are more than one. + pub stacks: Vec, + /// The commit all workspace commits build on โ€” the boundary at the bottom beyond which + /// nothing belongs to the workspace. It is the longest path to the first commit shared + /// with the target across all stacks, or, without a target, the first commit all stacks + /// share. `None` when there is a single stack and no target โ€” nothing was integrated. + pub lower_bound: Option, + /// The target, as identified by a remote tracking branch, to integrate workspace stacks into. + /// + /// If `None`, and if `target_commit` is `None`, this is a local workspace that doesn't know when + /// possibly pushed branches are considered integrated. This happens when there is a local branch + /// checked out without a remote tracking branch. + pub target_ref: Option, + /// A commit *typically* reachable by [`Self::target_ref`] which we chose to keep as base. That way we can extend the workspace + /// past its computed lower bound. + /// + /// Indeed, it's valid to not set the reference, and to only set the commit which should act as an integration base. + /// This can be done by direct user override, who simply wants to cut off history at a certain movable point in time. + /// + /// It is also valid to have this field point to the same Segment as [Self::target_ref]. Both have different purposes, + /// semantically. + pub target_commit: Option, + /// Read-only workspace metadata with additional information, or `None` if nothing was present. + /// If this is `Some()` the `kind` is always [`WorkspaceKind::Managed`] + /// + /// # WARNING + /// + /// Do not use this data to understand the workspace. It's unreconciled metadata which may + /// have nothing to do with the actual workspace. + /// To see that, look at [Self::stacks]. + pub metadata: Option, + + // โ”€โ”€ The carried substrate. โ”€โ”€ + /// The commit graph the workspace was projected from โ€” the commit-addressed + /// substrate all queries answer from. + pub(crate) commit_graph: crate::CommitGraph, + /// The build-time context: options, project metadata, and repository-derived + /// remote relationships. + pub(crate) ctx: GraphContext, + + // โ”€โ”€ Facts captured at projection time, before the segment view is dropped. โ”€โ”€ + /// The ref info of the workspace tip segment. + pub(crate) tip_ref_info: Option, + /// The commit the workspace tip segment rests on. + pub(crate) tip_commit_id: Option, + /// The name of the segment owning the workspace's lower bound. + pub(crate) lower_bound_ref_name: Option, + /// The traversal entrypoint as the projection saw it. `None` only for hand-assembled + /// graphs whose entrypoint was never set. + pub(crate) entrypoint: Option, + /// The commit the target ref's segment rests on. + pub(crate) target_ref_commit_id: Option, + /// The commit acting as the workspace target โ€” target ref tip, then stored target + /// commit, then the first integrated traversal tip. + pub(crate) effective_target_commit_id: Option, + /// The commits in the ancestry of the workspace tip segment โ€” excluding that segment's + /// own commits โ€” in traversal order down to the lower bound. + pub(crate) commits_below_tip: Vec, + /// The target-side commits ahead of the workspace base, from the target tip downward. + /// `None` without a resolved target ref. + pub(crate) incoming_target_commit_ids: Option>, + /// Whether more than one unique worktree is referenced by the graph โ€” the commit debug + /// renderer keys ownership markers off it. + pub(crate) has_multiple_worktrees: bool, +} + +/// Construction: the workspace-level entry points, mirroring the graph traversals. These are +/// what operations use โ€” the segment view is a build/projection detail, and nothing +/// outside the crate can build one. (The fold: builder and projection fuse behind these.) +impl Workspace { + /// An empty ad-hoc workspace over an empty graph, for tests that only exercise + /// projection-level data. + #[doc(hidden)] + pub fn empty_ad_hoc_for_testing() -> Self { + Workspace { + kind: WorkspaceKind::AdHoc, + stacks: vec![], + lower_bound: None, + target_ref: None, + target_commit: None, + metadata: None, + commit_graph: crate::CommitGraph::default(), + ctx: GraphContext::default(), + tip_ref_info: None, + tip_commit_id: None, + lower_bound_ref_name: None, + entrypoint: None, + target_ref_commit_id: None, + effective_target_commit_id: None, + commits_below_tip: Vec::new(), + incoming_target_commit_ids: None, + has_multiple_worktrees: false, + } + } + + /// Build the workspace as seen from `HEAD` โ€” the standard entry point. + pub fn from_head( + repo: &gix::Repository, + meta: &impl but_core::RefMetadata, + project_meta: but_core::ref_metadata::ProjectMeta, + options: crate::walk::Options, + ) -> anyhow::Result { + let (cg, ctx) = crate::CommitGraph::from_head(repo, meta, project_meta, options)?; + project_workspace(cg, ctx) + } + + /// Build the workspace as seen from `tip` (a checkout or preview position), named by + /// `ref_name` if the position is checked out as a ref. + pub fn from_tip( + tip: gix::Id<'_>, + ref_name: impl Into>, + meta: &impl but_core::RefMetadata, + project_meta: but_core::ref_metadata::ProjectMeta, + options: crate::walk::Options, + ) -> anyhow::Result { + let (cg, ctx) = crate::CommitGraph::from_tip(tip, ref_name, meta, project_meta, options)?; + project_workspace(cg, ctx) + } + + /// Build the workspace from multiple `tips` at once โ€” the multi-tip variant of + /// [`Self::from_tip`]. + pub fn from_seeds( + repo: &gix::Repository, + tips: impl IntoIterator, + meta: &impl but_core::RefMetadata, + project_meta: but_core::ref_metadata::ProjectMeta, + options: crate::walk::Options, + ) -> anyhow::Result { + let (cg, ctx) = crate::CommitGraph::from_seeds(repo, tips, meta, project_meta, options)?; + project_workspace(cg, ctx) + } + + /// Re-read the world with in-memory `overlay` refs and metadata, yielding the workspace as + /// it would look after applying them. + pub fn redo( + &self, + repo: &gix::Repository, + meta: &impl but_core::RefMetadata, + overlay: crate::walk::Overlay, + ) -> anyhow::Result { + let overlay_for_flip = overlay.clone(); + let (repo, meta, entrypoint) = overlay.into_parts(repo, meta); + let (seed, ref_name) = match entrypoint { + Some(t) => t, + None => { + let ep = self + .entrypoint + .as_ref() + .context("BUG: entrypoint must always be set")?; + let mut ref_name = ep.ref_name.clone(); + let seed = if let Some(name) = ref_name.as_ref() { + match repo.try_find_reference(name.as_ref())? { + Some(mut reference) => Some(reference.peel_to_id()?.detach()), + None => { + // The previous traversal may have had a named entrypoint, but + // this overlay can drop that ref. If so, don't carry a stale + // entrypoint_ref override into the new traversal; it would fail + // validation instead of re-traversing from the remembered commit. + ref_name = None; + None + } + } + } else { + None + }; + let seed = seed.or(ep.commit_id).context( + "BUG: entrypoint must either remember the original commit id or have a resolvable ref", + )?; + (seed, ref_name) + } + }; + // The same dispatch as `from_tip`, with the overlay served from memory by + // the builders. + let (cg, ctx) = crate::CommitGraph::dispatch_tip( + repo.for_attach_only(), + seed, + ref_name, + meta.for_inner_only(), + self.ctx.project_meta.clone(), + self.ctx.options.clone(), + overlay_for_flip, + )?; + project_workspace(cg, ctx) + } + + /// Validate the carried graph's invariants (see `CommitGraph::validation_errors`), passing `self` + /// through for chaining โ€” the workspace-level twin tests use after construction. + pub fn validated(self) -> anyhow::Result { + if let Some(err) = self.commit_graph.validation_errors().pop() { + return Err(err); + } + Ok(self) + } +} + +/// A classifier for the workspace. +#[derive(Debug, Clone)] +pub enum WorkspaceKind { + /// The `HEAD` is pointing to a dedicated workspace reference, like `refs/heads/gitbutler/workspace`. + /// This also means that we have a workspace commit that `ref_name` points to directly, which is also owned + /// exclusively by the underlying segment. + Managed { + /// The name of the reference pointing to the workspace commit, along with workspace info. Useful for deriving the workspace name. + ref_info: crate::RefInfo, + }, + /// Information for when a workspace reference was *possibly* advanced by hand and does not point to a + /// managed workspace commit (anymore). + /// That workspace commit, may be reachable by following the first parent from the workspace reference. + /// + /// The stacks that follow will be unusable if the workspace commit sits in a segment + /// below; with just one real stack, or only empty (ref-only) stacks, they typically + /// remain usable. + ManagedMissingWorkspaceCommit { + /// The name of the reference pointing to the workspace commit. Useful for deriving the workspace name. + ref_info: crate::RefInfo, + }, + /// A segment is checked out directly. + /// + /// It can be inside or outside a workspace. + /// If the respective segment is [not named](Workspace::ref_name), this means the `HEAD` is detached. + /// The commit that the working tree is at is always implied to be the first commit of the [`crate::workspace::StackSegment`] + /// at the workspace's tip segment. + AdHoc, +} + +impl WorkspaceKind { + /// Return `true` if this workspace has a managed reference, meaning we control certain aspects of it + /// by means of workspace metadata that is associated with that ref. + /// If `false`, we are more conservative and may not support all features. + pub fn has_managed_ref(&self) -> bool { + matches!( + self, + WorkspaceKind::Managed { .. } | WorkspaceKind::ManagedMissingWorkspaceCommit { .. } + ) + } + + /// Return `true` if we have a workspace commit, a commit that merges all stacks together. + /// Implies `has_managed_ref() == true`. + pub fn has_managed_commit(&self) -> bool { + matches!(self, WorkspaceKind::Managed { .. }) + } +} + +/// Information about the target reference, which marks a portion in the commit-graph +/// that the workspace wants to integrate with. +#[derive(Clone)] +pub struct TargetRef { + /// The name of the target branch, i.e. the branch that all [Stacks](Stack) want to get merged into. + /// Typically, this is `refs/remotes/origin/main`. + pub ref_name: gix::refs::FullName, + /// The commit the target rests on, resolved through empty segments. + pub(crate) tip_commit_id: Option, + /// The number of commits on the target that the workspace doesn't have yet โ€” its future. + pub commits_ahead: usize, +} + +/// Information about the target commit, which marks a portion in the commit-graph +/// that the workspace wants to integrate with. +/// +/// It's an unnamed point of interest which may +/// be set by any means. Typically, it's set by using a stored value, which makes it +/// a point in time at which we have seen the [`TargetRef`]. +#[derive(Clone)] +pub struct TargetCommit { + /// The hash of the commit that was once included in the [target ref](TargetRef), and that we remember to expand + /// the reach of the workspace. + pub commit_id: gix::ObjectId, +} + +/// The resolved tip is a lookup detail; keep snapshot output to the stable facts. +impl std::fmt::Debug for TargetRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TargetRef") + .field("ref_name", &self.ref_name) + .field("commits_ahead", &self.commits_ahead) + .finish() + } +} + +impl std::fmt::Debug for TargetCommit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TargetCommit") + .field("commit_id", &self.commit_id) + .finish() + } +} + +/// The commits upstream of `target_tip`: everything reachable from it that is +/// neither the workspace's shared history nor the workspace itself. +/// +/// Shared-history semantics (the disjoint-target rule): paint the lower bound's +/// ancestor set, then collect from the target tip until the walk TOUCHES shared +/// history โ€” the paint, or any workspace-reachable commit. Diverged commits are +/// never in either, so a rewritten remote is collected at any depth. A walk that +/// never touches shared history found a DISJOINT target โ€” nothing is upstream โ€” +/// unless either side's ancestry ends at a traversal CUT, in which case the +/// shared base may lie beyond the window and everything visible stays. +pub(crate) fn upstream_commits( + cg: &crate::CommitGraph, + target_tip: gix::ObjectId, + lower_bound: Option, +) -> Vec { + let shared_history = + lower_bound.and_then(|lb| cg.node(lb).is_some().then(|| cg.ancestor_marks(lb))); + let mut touched_shared_history = false; + let mut collected = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut runs = std::collections::VecDeque::from([target_tip]); + while let Some(run_start) = runs.pop_front() { + let mut cursor = Some(run_start); + while let Some(id) = cursor.take() { + if !seen.insert(id) { + break; + } + let Some(node) = cg.node(id) else { break }; + let in_shared_history = shared_history + .as_ref() + .is_some_and(|shared| cg.index_of(id).is_some_and(|i| shared[i])); + let in_workspace = node.flags.contains(crate::CommitFlags::InWorkspace); + touched_shared_history |= in_shared_history || in_workspace; + if Some(id) == lower_bound || in_shared_history || in_workspace { + break; + } + collected.push(id); + let parents = cg.all_parent_ids(id); + cursor = parents.first().copied(); + runs.extend(parents.into_iter().skip(1)); + } + } + if let Some(shared) = shared_history.as_ref().filter(|_| !touched_shared_history) { + let genuinely_disjoint = !collected.iter().any(|id| cg.has_cut_parents(*id)) + && !shared + .iter() + .enumerate() + .any(|(i, &marked)| marked && cg.has_cut_parents_at(i)); + if genuinely_disjoint { + return Vec::new(); + } + } + collected +} + +fn find_segment_owner_indexes_by_refname( + stacks: &[Stack], + ref_name: &gix::refs::FullNameRef, +) -> Option<(usize, usize)> { + stacks.iter().enumerate().find_map(|(stack_idx, stack)| { + stack + .segments + .iter() + .enumerate() + .find_map(|(seg_idx, seg)| { + seg.ref_name() + .is_some_and(|rn| rn == ref_name) + .then_some((stack_idx, seg_idx)) + }) + }) +} diff --git a/crates/but-graph/src/projection/stack.rs b/crates/but-graph/src/projection/stack.rs index a3069ef3588..565e8e78311 100644 --- a/crates/but-graph/src/projection/stack.rs +++ b/crates/but-graph/src/projection/stack.rs @@ -1,12 +1,13 @@ +//! The shapes the projection emits: [`Stack`]s of [`StackSegment`]s holding +//! [`StackCommit`]s โ€” a deliberately simplified, display-ready view that degenerates +//! graph detail while keeping ids that link back to it. + use std::fmt::Formatter; -use anyhow::{Context as _, bail}; use bitflags::bitflags; use but_core::{ref_metadata, ref_metadata::StackId}; -use gix::prelude::ObjectIdExt; -use petgraph::Direction; -use crate::{CommitFlags, Graph, SegmentIndex, SegmentMetadata, init::PetGraph}; +use crate::CommitFlags; /// A list of segments that together represent a list of dependent branches, stacked on top of each other. #[derive(Clone)] @@ -21,13 +22,6 @@ pub struct Stack { /// Query impl Stack { - /// Return the first commit of the first segment, or `None` this stack is completely empty, or has only empty segments. - pub fn tip(&self) -> Option { - self.segments - .first() - .and_then(|s| s.commits.first().map(|c| c.id)) - } - /// Return the name of the first segment of the stack. pub fn ref_name(&self) -> Option<&gix::refs::FullNameRef> { self.segments.first().and_then(|s| s.ref_name()) @@ -47,16 +41,13 @@ impl Stack { pub fn base(&self) -> Option { self.segments.last().and_then(|s| s.base) } - - /// The [base_segment_id](StackSegment::base_segment_id) of the last of our segments. - pub fn base_segment_id(&self) -> Option { - self.segments.last().and_then(|s| s.base_segment_id) - } } impl Stack { - pub(crate) fn from_base_and_segments( - graph: &PetGraph, + /// Like [`Self::from_base_and_segments`], but without a graph to recompute the + /// last base from โ€” the arena-derived collection threads bases purely from the + /// segments themselves. + pub(crate) fn from_base_and_segments_raw( mut segments: Vec, id: Option, ) -> Self { @@ -64,41 +55,29 @@ impl Stack { let mut cur = iter.next(); while let Some((a, b)) = cur.zip(iter.next()) { a.base = b.commits.first().map(|c| c.id); - a.base_segment_id = b.id.into(); cur = Some(b); } - let mut stack = Stack { id, segments }; - stack.recompute_last_segment_base(graph); - stack + Stack { id, segments } } - /// Recompute the last segment's base from its bottom commit's first-parent neighbour. + /// Recompute the last segment's base from the stack's bottom commit's first parent. /// /// Needed after the stack's bottom is truncated (e.g. by integrated-trunk pruning), - /// which leaves the collection-time base pointing at a segment no longer in the stack. - pub(crate) fn recompute_last_segment_base(&mut self, graph: &PetGraph) { - let Some((last_segment, last_aggregated_sidx)) = self.segments.last_mut().and_then(|s| { - let sidx = s.commits_by_segment.last().map(|t| t.0)?; - Some((s, sidx)) - }) else { + /// which leaves the collection-time base pointing below territory no longer in the + /// stack. + pub(crate) fn recompute_last_segment_base_from(&mut self, cg: &crate::CommitGraph) { + let bottom = self + .segments + .iter() + .rev() + .find_map(|s| s.commits.last().map(|c| c.id)); + let Some(last_segment) = self.segments.last_mut() else { return; }; - let first_parent_sidx = graph - .neighbors_directed(last_aggregated_sidx, Direction::Outgoing) - .next(); - last_segment.base = first_parent_sidx.and_then(|sidx| { - graph[sidx].commits.first().and_then(|c| { - if c.parent_ids.is_empty() || graph[sidx].commits.get(1).is_some() { - return c.id.into(); - } - graph - .neighbors_directed(sidx, Direction::Outgoing) - .next() - .is_some() - .then_some(c.id) - }) - }); - last_segment.base_segment_id = first_parent_sidx.filter(|_| last_segment.base.is_some()); + let Some(bottom) = bottom else { + return; + }; + last_segment.base = cg.all_parent_ids(bottom).first().copied(); } } @@ -119,12 +98,12 @@ impl Stack { /// Like [`Self::debug_string()`], but includes graph-contextual worktree ownership markers. pub fn debug_string_with_graph_context( &self, - graph: &Graph, + ws: &crate::Workspace, id_override: Option, ) -> String { let mut dbg = self.segments.first().map_or_else( || "".into(), - |s| s.debug_string_with_graph_context(graph), + |s| s.debug_string_with_graph_context(ws), ); self.push_debug_suffix(&mut dbg, id_override); dbg @@ -163,14 +142,14 @@ impl std::fmt::Debug for Stack { /// A typically named set of linearized commits, obtained by first-parent-only traversal. /// -/// Note that this maybe an aggregation of multiple [graph segments](crate::Segment). +/// Note that this maybe an aggregation of multiple `graph segments`. /// /// ### WARNING /// /// As it stands, we may 'doctor' the `ref_name`, `remote_tracking_ref_name` and `metadata` *if* `commits_outside` is not /// `None`. This is to help with visualisation, but makes this data much less usable in algorithms, at least if /// these fields are significant. -#[derive(Clone)] +#[derive(Clone, Default)] pub struct StackSegment { /// The unambiguous or disambiguated name of the branch at the tip of the segment, i.e. at the first commit, /// along with its worktree information. @@ -184,24 +163,10 @@ pub struct StackSegment { /// The name of the remote tracking branch of this segment, if present, i.e. `refs/remotes/origin/main`. /// Its presence means [`commits_outside`](Self::commits_outside) are possibly available. pub remote_tracking_ref_name: Option, - /// If `remote_tracking_ref_name` is `None`, and `ref_name` is a remote tracking branch, then this is set to be - /// the segment id of the local tracking branch, effectively doubly-linking them for ease of traversal. - /// If `ref_name` is `None` and this segment is the ancestor of a named segment that is known to a workspace, - /// this id is pointing to that named segment to allow the reconstruction of the originally desired workspace. - pub sibling_segment_id: Option, - /// If `remote_tracking_ref_name` is set, this field is also set to make accessing the respective segment easy, - /// avoiding a search through the entire graph. - /// It *only* ever points to the remote tracking branch segment. - pub remote_tracking_branch_segment_id: Option, - /// An ID which uniquely identifies the [first graph segment](crate::Segment) that is contained - /// in this instance. - /// This is always the first id in the `commits_by_segment`. - /// Note that it's not suitable to permanently identify the segment, so should not be persisted, - /// and is only stable within this graph as it exists right now. Traversing the graph again will yield - /// different IDs in an unpredictable way as the underlying commit-graph may have changed. - /// Also, one cannot assume that one of its commits belongs to a graph segment of this ID directly, - /// there is no 1:1 mapping. - pub id: SegmentIndex, + /// The segment is anonymous in the graph; the name, metadata, and remote shown here were + /// projected from its out-of-workspace sibling (an advanced branch whose tip left the + /// workspace), to allow reconstructing the originally desired workspace. + pub name_projected_from_outside: bool, /// The portion of commits that can be reached from the tip of the *branch* downwards to the next [StackSegment], /// so that they are unique for this stack segment and not included in any other stack or stack segment. /// The walk is performed **along the first parent only**. @@ -210,10 +175,8 @@ pub struct StackSegment { pub commits: Vec, /// All commits *that are not workspace commits* reachable by (and including commits in) this segment. /// The list was created by walking all parents, not only the first parent. - /// Note that the tips of these commits is the `sibling_segment_id` which in this case is `Some` - /// if this field is `Some`. - /// When set, we will also have copied the `ref_name`, `metadata` and `remote_tracking_ref_name` from - /// `sibling_segment_id` over to this segment to provide more meaningful information. + /// When set, the `ref_name`, `metadata` and `remote_tracking_ref_name` were copied from + /// the out-of-workspace sibling segment that owns these commits. pub commits_outside: Option>, /// This is always the `first()` commit in `commits` of the next stacksegment, or the first commit of /// the first ancestor segment. @@ -222,14 +185,6 @@ pub struct StackSegment { /// It is `None` if the stack segment contains the first commit in the history, an orphan without ancestry, /// or if the history traversal was stopped early. pub base: Option, - /// If `base` is set, this is the segment owning the commit. - /// This is particularly interesting if this is the bottom-most segment in a stack as it typically connects to - /// the first segment outside the stack. - pub base_segment_id: Option, - /// A mapping of `(segment_idx, offset)` to know which segment contributed the commits of the - /// given offset into `commits`. The offsets are ascending, starting at `0`. - /// This is useful to be able to retain the ability to associate a commit to a segment in the graph. - pub commits_by_segment: Vec<(SegmentIndex, usize)>, /// Commits that are *only* reachable from the tip of the remote-tracking branch that is associated with this branch, /// down to the first (and possibly unrelated) non-remote commit. /// Note that these commits may not have an actual commit-graph connection to the local @@ -239,13 +194,21 @@ pub struct StackSegment { pub commits_on_remote: Vec, /// Read-only branch metadata with additional information, or `None` if nothing was present. pub metadata: Option, - /// This is `true` for exactly one segment in a workspace if the entrypoint of [the traversal](Graph::from_commit_traversal()) + /// This is `true` for exactly one segment in a workspace if the entrypoint of `the traversal`) /// is this segment, and the surrounding workspace is provided for context. /// This means one will see the entire workspace, while knowing the focus is on one specific segment. pub is_entrypoint: bool, } /// Access +impl StackSegment { + /// An empty segment for tests outside this crate that assemble projections by hand. + #[doc(hidden)] + pub fn default_for_testing() -> Self { + Self::default() + } +} + impl StackSegment { /// Return the top-most commit id, or `None` if this segment is empty. pub fn tip(&self) -> Option { @@ -256,28 +219,6 @@ impl StackSegment { pub fn ref_name(&self) -> Option<&gix::refs::FullNameRef> { self.ref_info.as_ref().map(|ri| ri.ref_name.as_ref()) } - - /// Returns an iterator over all reachable reference names, that is the name of the segment if present - /// and all ref-names pointing to/stored in local commits. - pub fn ref_names(&self) -> impl Iterator { - self.ref_info - .as_ref() - .map(|ri| ri.ref_name.as_ref()) - .into_iter() - .chain( - self.commits - .iter() - .flat_map(|c| c.refs.iter().map(|ri| ri.ref_name.as_ref())), - ) - } - - /// Return `true` if this segment *would* be anonymous if it wasn't for the out-of-workspace segment to be projected onto this one. - /// - /// This is signaled by its underlying graph segment being unnamed, with a sibling set. - pub fn is_projected_from_outside(&self, graph: &Graph) -> bool { - let segment = &graph[self.id]; - segment.ref_info.is_none() && segment.sibling_segment_id.is_some() - } } impl std::fmt::Debug for StackSegment { @@ -291,129 +232,10 @@ impl std::fmt::Debug for StackSegment { } impl StackSegment { - /// Given a list of *graph* `segments` to aggregate, produce a stack segment that is like the combination - /// of a remote segment and a local ones, along with more detailed commits and (if possible) without - /// anonymous portions. - /// - /// It's like reconstructing a first-parent traversal from the segmented graph, which splits each time there - /// is an unambiguous ref pointing to a commit, or when it splits a segment by incoming connection. - /// - /// `graph` is used to look up the remote segment and find its commits. - pub(crate) fn from_graph_segments( - segments: &[&crate::Segment], - graph: &Graph, - ) -> anyhow::Result { - let mut segments_iter = segments.iter(); - let &&crate::Segment { - id, - generation: _, - ref_info: ref ref_name, - ref remote_tracking_ref_name, - sibling_segment_id, - remote_tracking_branch_segment_id, - commits: _, - ref metadata, - } = segments_iter - .next() - .context("BUG: need one or more segments")?; - - let mut commits_by_segment = Vec::new(); - let mut is_first = true; - let (mut ref_name, mut metadata, mut remote_tracking_ref_name) = - (ref_name, metadata, remote_tracking_ref_name); - let mut commits_outside = None::>; - for s in segments { - let mut stack_commits = Vec::new(); - if let Some(sibling_sidx) = s - .sibling_segment_id - .filter(|_| is_first && ref_name.is_none()) - { - let sibling = &graph[sibling_sidx]; - ref_name = &sibling.ref_info; - metadata = &sibling.metadata; - remote_tracking_ref_name = &sibling.remote_tracking_ref_name; - graph.visit_all_segments_including_start_until( - sibling_sidx, - Direction::Outgoing, - |s| { - let prune = true; - if s.commits - .iter() - .any(|c| c.flags.contains(CommitFlags::InWorkspace)) - { - return prune; - } - commits_outside - .get_or_insert_default() - .extend(s.commits.iter().map(StackCommit::from_graph_commit)); - !prune - }, - ); - } - for commit in &s.commits { - stack_commits.push(StackCommit::from_graph_commit(commit)); - } - commits_by_segment.push((s.id, stack_commits)); - is_first = false; - } - // The last (actual) segment could be partial. - if let Some(commits) = commits_by_segment.last_mut().and_then(|(sidx, commits)| { - graph - .stop_condition(*sidx) - .is_some_and(|condition| condition.at_limit()) - .then_some(commits) - }) && let Some(commit) = commits.last_mut() - { - commit.flags |= StackCommitFlags::EarlyEnd; - } - - Ok(StackSegment { - ref_info: ref_name.clone(), - id, - remote_tracking_ref_name: remote_tracking_ref_name.clone(), - sibling_segment_id, - remote_tracking_branch_segment_id, - // `base` is set later in the context of the entire stack. - base: None, - base_segment_id: None, - commits_by_segment: { - let mut ofs = 0; - commits_by_segment - .iter() - .map(|(sidx, commits)| { - let res = (*sidx, ofs); - ofs += commits.len(); - res - }) - .collect() - }, - commits: commits_by_segment - .into_iter() - .flat_map(|(_sid, commits)| commits) - .collect(), - commits_outside, - // Will be set later once all stacks are known. - commits_on_remote: Vec::new(), - metadata: metadata - .as_ref() - .map(|md| match md { - SegmentMetadata::Branch(md) => Ok(md.clone()), - SegmentMetadata::Workspace(_) => { - bail!( - "BUG: Should always stop stacks at workspaces, \ - but got a stack that thinks it's a workspace" - ) - } - }) - .transpose()?, - is_entrypoint: false, /* to be set later */ - }) - } - /// Digest as much as possible into a single line. pub fn debug_string(&self) -> String { // Segment indices are ephemeral; keep them out of snapshot output. - self.debug_string_with_ref_name_remote(Graph::ref_and_remote_debug_string( + self.debug_string_with_ref_name_remote(crate::debug::ref_and_remote_debug_string( self.ref_info.as_ref(), self.remote_tracking_ref_name.as_ref(), None, @@ -422,15 +244,14 @@ impl StackSegment { } /// Like [`Self::debug_string()`], but includes graph-contextual worktree ownership markers. - pub fn debug_string_with_graph_context(&self, graph: &Graph) -> String { - self.debug_string_with_ref_name_remote( - graph.ref_and_remote_debug_string_with_graph_context( - self.ref_info.as_ref(), - self.remote_tracking_ref_name.as_ref(), - None, - None, - ), - ) + pub fn debug_string_with_graph_context(&self, ws: &crate::Workspace) -> String { + self.debug_string_with_ref_name_remote(crate::debug::ref_and_remote_debug_string_inner( + self.ref_info.as_ref(), + self.remote_tracking_ref_name.as_ref(), + None, + None, + ws.has_multiple_worktrees, + )) } fn debug_string_with_ref_name_remote(&self, ref_name_remote: String) -> String { @@ -480,27 +301,13 @@ pub struct StackCommit { /// Utilities impl StackCommit { - /// Attach this commit to `repo` for more detailed access of the commit itself - /// via [`but_core::Commit`]. - /// - /// # Performance Warning - /// - /// Don't do this light-heartedly as it decodes the commit, parses it, *and* copies - /// all fields into an owned instance. This is expensive. - pub fn attach<'repo>( - &self, - repo: &'repo gix::Repository, - ) -> anyhow::Result> { - but_core::Commit::from_id(self.id.attach(repo)) - } - /// Return an iterator over all reference names that point to this commit. pub fn ref_iter(&self) -> impl Iterator + Clone { self.refs.iter().map(|ri| ri.ref_name.as_ref()) } /// Collect additional information on `commit` using `repo`. - pub fn from_graph_commit(commit: &crate::Commit) -> Self { + pub(crate) fn from_graph_commit(commit: &crate::Commit) -> Self { StackCommit { id: commit.id, parent_ids: commit.parent_ids.clone(), @@ -552,7 +359,10 @@ impl StackCommit { self.refs .iter() .map(|ri| format!("โ–บ{}", { - Graph::ref_debug_string(ri.ref_name.as_ref(), ri.worktree.as_ref()) + crate::debug::ref_debug_string( + ri.ref_name.as_ref(), + ri.worktree.as_ref(), + ) })) .collect::>() .join(", ") diff --git a/crates/but-graph/src/projection/workspace/api/queries.rs b/crates/but-graph/src/projection/workspace/api/queries.rs deleted file mode 100644 index 7d925b3e024..00000000000 --- a/crates/but-graph/src/projection/workspace/api/queries.rs +++ /dev/null @@ -1,165 +0,0 @@ -//! Discoverable queries over [`Workspace`](crate::Workspace). -//! -//! These functions name the question being asked instead of exposing legacy -//! presentation shapes. - -use anyhow::Context; - -use crate::{RefInfo, SegmentIndex, Workspace, segment, workspace::TargetRef}; - -/// Legacy query helpers kept for callers that still depend on compatibility -/// semantics. -#[cfg(feature = "legacy")] -#[path = "legacy.rs"] -pub mod legacy; - -/// # Points of Interest -impl Workspace { - /// Return the `commit` at the tip of the workspace, or that the tip reference - /// was pointing to in Git. - /// - /// Empty virtual workspace tip segments may fan out to multiple stack - /// branches, so the workspace segment has no unique graph path to a commit. - /// This falls back to the peeled commit id stored in the workspace segment's - /// [`crate::RefInfo`] and resolves that id against the final graph. - /// - /// Note that this commit could also be the base of the workspace, - /// particularly if there are no commits in the workspace. - pub fn tip_commit(&self) -> Option<&segment::Commit> { - self.tip_commit_by_segment_id(self.id) - } - - /// Return the `commit` at the tip of `segment_id`, or that its ref was pointing - /// to in Git. - /// - /// This first uses [`Graph::tip_skip_empty()`](crate::Graph::tip_skip_empty) - /// to follow an unambiguous chain of empty segments to the first commit. - /// If that cannot resolve a commit, it falls back to the peeled commit id - /// stored in the segment's [`crate::RefInfo`] and resolves that id in the - /// graph. - /// - /// That fallback is what makes this useful for workspace-owned virtual - /// segments whose ref points at a commit, but whose graph edges do not form - /// a single unambiguous path to it. - pub fn tip_commit_by_segment_id(&self, segment_id: SegmentIndex) -> Option<&segment::Commit> { - self.graph.tip_skip_empty(segment_id).or_else(|| { - let commit_id = self.graph[segment_id].ref_info.as_ref()?.commit_id?; - self.graph - .segment_by_commit_id(commit_id) - .ok()? - .commit_by_id(commit_id) - }) - } - - /// Return the stored target commit id. - /// - /// This is the previous target position remembered in workspace metadata. - /// It is normally the base the workspace last integrated with, and - /// intentionally differs from [`Self::target_ref_tip_commit_id()`], which - /// returns the current tip of the target reference. - pub fn stored_target_commit_id(&self) -> Option { - self.target_commit.as_ref().map(|target| target.commit_id) - } - - /// Return the current tip commit id of the target reference if it is - /// present in the workspace graph. - pub fn target_ref_tip_commit_id(&self) -> Option { - self.target_ref - .as_ref() - .and_then(|target| self.tip_commit_by_segment_id(target.segment_index)) - .map(|commit| commit.id) - } - - /// Return the commit id that currently acts as the workspace target. - /// - /// This follows the same precedence as operations that need a concrete - /// target side: target ref tip, then stored target commit, then the first - /// integrated traversal tip. - pub fn effective_target_commit_id(&self) -> Option { - self.target_ref - .as_ref() - .and_then(|target| self.tip_commit_by_segment_id(target.segment_index)) - .map(|commit| commit.id) - .or_else(|| self.target_commit.as_ref().map(|target| target.commit_id)) - .or_else(|| { - self.graph - .integrated_tip_segments() - .into_iter() - .find_map(|segment_index| { - self.tip_commit_by_segment_id(segment_index) - .map(|commit| commit.id) - }) - }) - } - - /// Return the segment that currently acts as the workspace target. - /// - /// This follows target ref, then stored target commit, then the first - /// integrated traversal tip in that order. - pub fn effective_target_segment_index(&self) -> Option { - self.target_ref - .as_ref() - .map(|target| target.segment_index) - .or(self - .target_commit - .as_ref() - .map(|target| target.segment_index)) - .or_else(|| self.graph.integrated_tip_segments().into_iter().next()) - } -} - -/// # Refs of Interest -impl Workspace { - /// Return the configured target reference name if the workspace target was - /// resolved to a branch during graph traversal. - /// This is mere convenience and it should only be used for displaying the target ref. - /// For everything else, use [`Self::target_ref`]. - pub fn target_ref_name(&self) -> Option<&gix::refs::FullNameRef> { - self.target_ref - .as_ref() - .map(|target| target.ref_name.as_ref()) - } - - /// Return the local tracking branch reference information with the configured - /// [target reference](Self::target_ref). This is available as long as a target - /// ref exists (i.e. `refs/remotes/origin/main`) and a local tracking ref for it - /// was configured or inferred. - pub fn target_local_tracking_ref_info(&self) -> Option<&RefInfo> { - self.target_ref - .as_ref() - .and_then(|target_ref| self.graph[target_ref.segment_index].sibling_segment_id) - .and_then(|local_target_ref_sidx| self.graph[local_target_ref_sidx].ref_info.as_ref()) - } -} - -/// # Sets of Interest -impl Workspace { - /// Return all target-reference commits that are ahead of the workspace base, - /// which is the commits counted with - /// [workspace::TargetRef::commits_ahead](crate::workspace::TargetRef::commits_ahead) - /// - /// The traversal starts at the resolved target reference and stops at the - /// workspace lower bound or at commits already marked as belonging to the - /// workspace. The result is ordered in graph traversal order from newer - /// commits toward older commits. - pub fn incoming_target_commit_ids(&self) -> anyhow::Result> { - let target_ref = self - .target_ref - .as_ref() - .context("incoming target commits require a workspace with a target ref")?; - let lower_bound = self - .lower_bound_segment_id - .map(|segment_id| (segment_id, self.graph[segment_id].generation)); - - let mut commit_ids = Vec::new(); - TargetRef::visit_upstream_commits( - &self.graph, - target_ref.segment_index, - lower_bound, - |segment| { - commit_ids.extend(segment.commits.iter().map(|commit| commit.id)); - }, - ); - Ok(commit_ids) - } -} diff --git a/crates/but-graph/tests/fixtures/scenarios.sh b/crates/but-graph/tests/fixtures/scenarios.sh index f34bdfe3fea..462b02a80b8 100644 --- a/crates/but-graph/tests/fixtures/scenarios.sh +++ b/crates/but-graph/tests/fixtures/scenarios.sh @@ -1671,4 +1671,60 @@ EOF commit x2 create_workspace_commit_once X ) + + # โ”€โ”€ applied-main corners: main as an ordinary (metadata-applied) branch โ”€โ”€ + # (a) main rests at the workspace base and is NOT a workspace-commit parent; + # only metadata membership can bring it in (the empty-lane splice machinery). + git init applied-main-at-base + (cd applied-main-at-base + commit M1 + setup_target_to_match_main + git checkout -b A + commit A1 + git checkout -b B main + commit B1 + create_workspace_commit_once A B + ) + + # (b) main has its OWN commit ahead of origin/main and is the workspace commit's + # first parent โ€” a real lane with commits, ahead of its remote like any branch. + git init applied-main-ahead + (cd applied-main-ahead + commit M1 + setup_target_to_match_main + git checkout -b A main + commit A1 + git checkout main + commit M2 + create_workspace_commit_once main A + ) + + # (c) main is a workspace-commit parent at the base while origin/main moved AHEAD โ€” + # the applied lane is behind its remote (integration will consider it integrated). + git init applied-main-behind + (cd applied-main-behind + commit M1 + git checkout -b A main + commit A1 + git checkout main + create_workspace_commit_once main A + git checkout -b soon-origin-main main + commit RM1 + git checkout gitbutler/workspace + add_main_remote_setup + setup_remote_tracking soon-origin-main main "move" + ) + + # (d) main (and its remote) advanced ABOVE another stack's fork point: A forked at M1, + # the base moved to M2 โ€” the stale-fork corner. + git init applied-main-above-fork + (cd applied-main-above-fork + commit M1 + git checkout -b A main + commit A1 + git checkout main + commit M2 + setup_target_to_match_main + create_workspace_commit_once main A + ) ) diff --git a/crates/but-graph/tests/fixtures/shared.sh b/crates/but-graph/tests/fixtures/shared.sh index e973c67c5d7..7f834615262 100644 --- a/crates/but-graph/tests/fixtures/shared.sh +++ b/crates/but-graph/tests/fixtures/shared.sh @@ -61,11 +61,14 @@ function create_workspace_commit_once() { fi fi - git checkout -b gitbutler/workspace - if [ $# == 1 ] || [ $# == 0 ]; then - git commit --allow-empty -m "$workspace_commit_subject" + if [ $# -gt 1 ]; then + # First arg becomes the workspace commit's first parent; the rest merge in order. + git checkout "$1" + git checkout -b gitbutler/workspace + git merge --no-ff -m "$workspace_commit_subject" "${@:2}" else - git merge --no-ff -m "$workspace_commit_subject" "${@}" + git checkout -b gitbutler/workspace + git commit --allow-empty -m "$workspace_commit_subject" fi } diff --git a/crates/but-graph/tests/graph/main.rs b/crates/but-graph/tests/graph/main.rs index 94f75cb632f..c8ada06aca0 100644 --- a/crates/but-graph/tests/graph/main.rs +++ b/crates/but-graph/tests/graph/main.rs @@ -1,4 +1,2 @@ -mod init; -mod merge_base; -mod vis; +mod walk; mod workspace; diff --git a/crates/but-graph/tests/graph/init/mod.rs b/crates/but-graph/tests/graph/walk/mod.rs similarity index 58% rename from crates/but-graph/tests/graph/init/mod.rs rename to crates/but-graph/tests/graph/walk/mod.rs index c69060430c2..5b46716f101 100644 --- a/crates/but-graph/tests/graph/init/mod.rs +++ b/crates/but-graph/tests/graph/walk/mod.rs @@ -1,10 +1,10 @@ use but_graph::{ - CommitFlags, Graph, StopCondition, - init::{Overlay, Tip}, + CommitFlags, Workspace, + walk::{Overlay, Seed}, }; use but_testsupport::{ gix_testtools::{self, Creation, rust_fixture_writable}, - graph_tree, graph_workspace, visualize_commit_graph_all, + graph_dag, graph_workspace, visualize_commit_graph_all, }; use gix::prelude::ObjectIdExt; use snapbox::prelude::*; @@ -13,79 +13,20 @@ use snapbox::prelude::*; fn unborn() -> anyhow::Result<()> { let (repo, meta) = read_only_in_memory_scenario("unborn")?; - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:main[๐ŸŒณ] - -"#]] - ); - snapbox::assert_data_eq!( - graph.to_debug(), - snapbox::str![[r#" -Graph { - inner: StableGraph { - Ty: "Directed", - node_count: 1, - edge_count: 0, - node weights: { - 0: Segment { - id: NodeIndex(0), - generation: 0, - ref_info: "โ–บmain[๐ŸŒณ]", - remote_tracking_ref_name: None, - sibling_segment_id: None, - remote_tracking_branch_segment_id: None, - commits: [], - metadata: "None", - }, - }, - edge weights: {}, - free_node: NodeIndex(4294967295), - free_edge: EdgeIndex(4294967295), - }, - entrypoint: Some( - ( - NodeIndex(0), - Unborn, - ), - ), - entrypoint_ref: None, - traversal_tips: [], - ad_hoc_branch_stack_orders: [], - hard_limit_hit: false, - options: Options { - collect_tags: false, - commits_limit_hint: None, - commits_limit_recharge_location: [], - hard_limit: None, - extra_target_commit_id: None, - dangerously_skip_postprocessing_for_debugging: false, - }, - project_meta: ProjectMeta { - target_ref: None, - target_commit_id: None, - push_remote: None, - }, - symbolic_remote_names: [], -} - -"#]] - ); + snapbox::assert_data_eq!(graph_dag(&ws), snapbox::str![" ๐Ÿ‘‰โ–บmain"]); assert!( - graph.managed_entrypoint_commit(&repo)?.is_none(), + ws.managed_entrypoint_commit_id(&repo)?.is_none(), "there is no commit it could return" ); snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" โŒ‚:main[๐ŸŒณ] <> โœ“! โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] {1} @@ -111,142 +52,41 @@ fn detached() -> anyhow::Result<()> { // Detached branches are forcefully made anonymous, and it's something // we only know by examining `HEAD`. - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ โ–บ:0[0]:anon: - โ””โ”€โ”€ ๐Ÿ‘‰ยท541396b (โŒ‚|1) โ–บtags/annotated, โ–บtags/release/v1, โ–บmain - โ””โ”€โ”€ โ–บ:1[1]:other - โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|1) - -"#]] - ); - snapbox::assert_data_eq!( - graph.to_debug(), - snapbox::str![[r#" -Graph { - inner: StableGraph { - Ty: "Directed", - node_count: 2, - edge_count: 1, - edges: (0, 1), - node weights: { - 0: Segment { - id: NodeIndex(0), - generation: 0, - ref_info: None, - remote_tracking_ref_name: None, - sibling_segment_id: None, - remote_tracking_branch_segment_id: None, - commits: [ - Commit(541396b, โŒ‚|1โ–บannotated, โ–บrelease/v1, โ–บmain), - ], - metadata: "None", - }, - 1: Segment { - id: NodeIndex(1), - generation: 1, - ref_info: "โ–บother", - remote_tracking_ref_name: None, - sibling_segment_id: None, - remote_tracking_branch_segment_id: None, - commits: [ - Commit(fafd9d0, โŒ‚|1), - ], - metadata: "None", - }, - }, - edge weights: { - 0: Edge { - src: Some( - 0, - ), - src_id: Some( - Sha1(541396b24e13b8ac45b7905c3fe8691c7fc5fbd0), - ), - dst: Some( - 0, - ), - dst_id: Some( - Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - ), - parent_order: 0, - }, - }, - free_node: NodeIndex(4294967295), - free_edge: EdgeIndex(4294967295), - }, - entrypoint: Some( - ( - NodeIndex(0), - AtCommit( - Sha1(541396b24e13b8ac45b7905c3fe8691c7fc5fbd0), - ), - ), - ), - entrypoint_ref: None, - traversal_tips: [ - Tip { - id: Sha1(541396b24e13b8ac45b7905c3fe8691c7fc5fbd0), - ref_name: None, - role: Reachable, - metadata: None, - is_entrypoint: true, - is_detached: false, - }, - ], - ad_hoc_branch_stack_orders: [], - hard_limit_hit: false, - options: Options { - collect_tags: true, - commits_limit_hint: None, - commits_limit_recharge_location: [], - hard_limit: None, - extra_target_commit_id: None, - dangerously_skip_postprocessing_for_debugging: false, - }, - project_meta: ProjectMeta { - target_ref: None, - target_commit_id: None, - push_remote: None, - }, - symbolic_remote_names: [], -} - + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท541396b (โŒ‚) โ–บmain, โ–บtags/annotated, โ–บtags/release/v1 +* ๐Ÿยทfafd9d0 (โŒ‚) โ–บother "#]] ); assert!( - graph.entrypoint()?.commit().map(|c| c.id).is_some(), + ws.entrypoint_commit_id()?.is_some(), "there is an entrypoint commit, detached or not" ); assert!( - graph.managed_entrypoint_commit(&repo)?.is_none(), + ws.managed_entrypoint_commit_id(&repo)?.is_none(), "but it's not managed" ); - let root_sidx = graph - .base_segments() - .find(|sidx| { - graph[*sidx] - .commits - .last() - .is_some_and(|commit| commit.parent_ids.is_empty()) - }) - .expect("root segment is present"); + let cg = ws.commit_graph(); + let root = cg + .commit_ids() + .find(|id| cg.node(*id).is_some_and(|n| n.parent_ids.is_empty())) + .expect("root commit is present"); assert_eq!( - graph.stop_condition(root_sidx), - Some(StopCondition::FirstCommit) + cg.connected_parents(root).count(), + 0, + "traversal naturally ended at the first commit" ); snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" โŒ‚:DETACHED <> โœ“! โ””โ”€โ”€ โ‰ก:anon: {1} @@ -280,7 +120,7 @@ fn shallow_clone_stops_at_shallow_boundary() -> anyhow::Result<()> { "the linear depth-2 clone should have exactly one shallow boundary" ); - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), @@ -288,27 +128,16 @@ fn shallow_clone_stops_at_shallow_boundary() -> anyhow::Result<()> { )? .validated()?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" - -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:0: - โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[1]:main[๐ŸŒณ] <> origin/main โ†’:1: - โ”œโ”€โ”€ ยท71a64f3 (โŒ‚|1) - โ””โ”€โ”€ โ›ฐยท62d65ed (โŒ‚|โ›ฐ|1) - +* ๐Ÿ‘‰ยท71a64f3 (โŒ‚) โ–บmain[๐ŸŒณ], โ–บorigin/main <> origin/main +* โ›ฐยท62d65ed (โŒ‚|โ›ฐ) "#]] ); - let (boundary_sidx, boundary_cidx) = graph - .segments() - .find_map(|sidx| { - graph[sidx] - .commits - .iter() - .position(|commit| commit.id == shallow_boundary_id) - .map(|cidx| (sidx, cidx)) - }) + let cg = ws.commit_graph(); + let boundary_commit = cg + .node(shallow_boundary_id) .expect("boundary commit is included in the graph"); - let boundary_commit = &graph[boundary_sidx].commits[boundary_cidx]; assert!( boundary_commit.flags.contains(CommitFlags::ShallowBoundary), "the boundary commit is explicitly flagged" @@ -319,21 +148,15 @@ fn shallow_clone_stops_at_shallow_boundary() -> anyhow::Result<()> { .copied() .expect("shallow boundary commit still records its grafted parent"); assert!( - graph.segments().all(|sidx| graph[sidx] - .commits - .iter() - .all(|commit| commit.id != missing_parent)), + cg.node(missing_parent).is_none(), "the grafted parent is not traversed" ); + assert_eq!( + cg.connected_parents(shallow_boundary_id).count(), + 0, + "the walk cut at the shallow boundary, not at a limit" + ); - let condition = graph - .stop_condition(boundary_sidx) - .expect("boundary segment has a cutoff condition"); - assert!(condition.contains(StopCondition::ShallowBoundary)); - assert!(!condition.contains(StopCondition::Limit)); - assert!(!condition.contains(StopCondition::FirstCommit)); - - let ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -370,7 +193,7 @@ fn merge_first_parent_older_non_workspace_maintains_graph_order() -> anyhow::Res .raw() ); - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), @@ -378,29 +201,23 @@ fn merge_first_parent_older_non_workspace_maintains_graph_order() -> anyhow::Res )? .validated()?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:first-parent[๐ŸŒณ] - โ””โ”€โ”€ ยท738ea18 (โŒ‚|1) - โ””โ”€โ”€ โ–บ:1[1]:anon: - โ””โ”€โ”€ ยท408ca26 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:3[2]:anon: - โ”‚ โ””โ”€โ”€ ยท2854fa2 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[3]:main - โ”‚ โ””โ”€โ”€ ๐Ÿยท793a434 (โŒ‚|1) โ–บtags/base - โ””โ”€โ”€ โ–บ:2[2]:second-parent - โ”œโ”€โ”€ ยท75369b0 (โŒ‚|1) - โ”œโ”€โ”€ ยท553bbf7 (โŒ‚|1) - โ””โ”€โ”€ ยท72614bb (โŒ‚|1) - โ””โ”€โ”€ โ†’:4: (main) - +* ๐Ÿ‘‰ยท738ea18 (โŒ‚) โ–บfirst-parent[๐ŸŒณ] +* ยท408ca26 (โŒ‚) +โ”œโ”€โ•ฎ +* โ”‚ ยท2854fa2 (โŒ‚) +โ”‚ * ยท75369b0 (โŒ‚) โ–บsecond-parent +โ”‚ * ยท553bbf7 (โŒ‚) +โ”‚ * ยท72614bb (โŒ‚) +โ”œโ”€โ•ฏ +* ๐Ÿยท793a434 (โŒ‚) โ–บmain, โ–บtags/base "#]] ); // we see only first-parent with two commits, not the 'second-parent' ref because it *seems* to be traversed first snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" โŒ‚:first-parent[๐ŸŒณ] <> โœ“! โ””โ”€โ”€ โ‰ก:first-parent[๐ŸŒณ] {1} @@ -431,30 +248,25 @@ fn main_advanced_remote_advanced() -> anyhow::Result<()> { "#]] ); - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:main[๐ŸŒณ] <> origin/main โ†’:1: -โ”‚ โ””โ”€โ”€ ยท971953d (โŒ‚|01) -โ”‚ โ””โ”€โ”€ โ–บ:2[1]:anon: -โ”‚ โ”œโ”€โ”€ ยทce09734 (โŒ‚|11) -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|11) -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:0: - โ””โ”€โ”€ ๐ŸŸฃ5d29d62 (0x0|10) - โ””โ”€โ”€ โ†’:2: - +* ๐Ÿ‘‰ยท971953d (โŒ‚) โ–บmain[๐ŸŒณ] <> origin/main +โ”‚ * ๐ŸŸฃ5d29d62 โ–บorigin/main +โ”œโ”€โ•ฏ +* ยทce09734 (โŒ‚) +* ๐Ÿยทfafd9d0 (โŒ‚) "#]] ); snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" โŒ‚:main[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on ce09734 โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] <> origin/mainโ‡ก1โ‡ฃ1 on ce09734 {1} @@ -483,25 +295,20 @@ fn only_remote_advanced() -> anyhow::Result<()> { "#]] ); - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" - -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:0: - โ””โ”€โ”€ ๐ŸŸฃ085535d (0x0|10) - โ””โ”€โ”€ โ–บ:2[1]:origin/split-segment - โ””โ”€โ”€ ๐ŸŸฃdd9f8d9 (0x0|10) - โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[2]:main[๐ŸŒณ] <> origin/main โ†’:1: - โ”œโ”€โ”€ ยท971953d (โŒ‚|11) - โ”œโ”€โ”€ ยทce09734 (โŒ‚|11) - โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|11) - +* ๐ŸŸฃ085535d โ–บorigin/main +* ๐ŸŸฃdd9f8d9 โ–บorigin/split-segment +* ๐Ÿ‘‰ยท971953d (โŒ‚) โ–บmain[๐ŸŒณ] <> origin/main +* ยทce09734 (โŒ‚) +* ๐Ÿยทfafd9d0 (โŒ‚) "#]] ); @@ -509,7 +316,7 @@ fn only_remote_advanced() -> anyhow::Result<()> { // This also affects the base which would have to be 085535d, the first commit. // which is strange but maybe can work? snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" โŒ‚:main[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ2 on 971953d โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] <> origin/mainโ‡ฃ1 {1} @@ -538,26 +345,20 @@ fn only_remote_advanced_with_special_branch_name() -> anyhow::Result<()> { "#]] ); - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" - -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:0: - โ””โ”€โ”€ ๐ŸŸฃ085535d (0x0|10) - โ””โ”€โ”€ โ–บ:3[1]:origin/split-segment - โ””โ”€โ”€ ๐ŸŸฃdd9f8d9 (0x0|10) - โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[2]:main[๐ŸŒณ] <> origin/main โ†’:1: - โ””โ”€โ”€ ยท971953d (โŒ‚|11) - โ””โ”€โ”€ โ–บ:2[3]:gitbutler/target - โ”œโ”€โ”€ ยทce09734 (โŒ‚|11) - โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|11) - +* ๐ŸŸฃ085535d โ–บorigin/main +* ๐ŸŸฃdd9f8d9 โ–บorigin/split-segment +* ๐Ÿ‘‰ยท971953d (โŒ‚) โ–บmain[๐ŸŒณ] <> origin/main +* ยทce09734 (โŒ‚) โ–บgitbutler/target +* ๐Ÿยทfafd9d0 (โŒ‚) "#]] ); @@ -565,7 +366,7 @@ fn only_remote_advanced_with_special_branch_name() -> anyhow::Result<()> { // isn't related to our stack and count its commits to `origin/main`. // Right now we are missing dd9f8d9. snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" โŒ‚:main[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ2 on 971953d โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] <> origin/mainโ‡ฃ1 {1} @@ -599,45 +400,39 @@ fn multi_root() -> anyhow::Result<()> { .raw() ); - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:main[๐ŸŒณ] - โ””โ”€โ”€ ยทc6c8c05 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:anon: - โ”‚ โ””โ”€โ”€ ยท76fc5c4 (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ โ–บ:3[2]:anon: - โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทe5d0542 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[2]:B - โ”‚ โ””โ”€โ”€ ๐Ÿยท366d496 (โŒ‚|1) - โ””โ”€โ”€ โ–บ:2[1]:C - โ””โ”€โ”€ ยท8631946 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:5[2]:anon: - โ”‚ โ””โ”€โ”€ ๐Ÿยท00fab2a (โŒ‚|1) - โ””โ”€โ”€ โ–บ:6[2]:D - โ””โ”€โ”€ ๐Ÿยทf4955b6 (โŒ‚|1) - +* ๐Ÿ‘‰ยทc6c8c05 (โŒ‚) โ–บmain[๐ŸŒณ] +โ”œโ”€โ•ฎ +* โ”‚ ยท76fc5c4 (โŒ‚) +โ”œโ”€โ”€โ”€โ•ฎ +* โ”‚ โ”‚ ๐Ÿยทe5d0542 (โŒ‚) + โ”‚ * ๐Ÿยท366d496 (โŒ‚) โ–บB + * ยท8631946 (โŒ‚) โ–บC +โ•ญโ”€โ”ค +โ”‚ * ๐Ÿยท00fab2a (โŒ‚) +* ๐Ÿยทf4955b6 (โŒ‚) โ–บD "#]] ); assert_eq!( - graph.tip_segments().count(), + ws.statistics().commits_at_tip, 1, "all leads to a single merge-commit" ); assert_eq!( - graph.base_segments().count(), + ws.statistics().commits_at_bottom, 4, "there are 4 orphaned bases" ); snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" โŒ‚:main[๐ŸŒณ] <> โœ“! โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] {1} @@ -676,53 +471,41 @@ fn four_diamond() -> anyhow::Result<()> { .raw() ); - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:merged[๐ŸŒณ] - โ””โ”€โ”€ ยท8a6c109 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:A - โ”‚ โ””โ”€โ”€ ยท62b409a (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ โ–บ:3[2]:anon: - โ”‚ โ”‚ โ””โ”€โ”€ ยท592abec (โŒ‚|1) - โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:7[3]:main - โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท965998b (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[2]:B - โ”‚ โ””โ”€โ”€ ยทf16dddf (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:7: (main) - โ””โ”€โ”€ โ–บ:2[1]:C - โ””โ”€โ”€ ยท7ed512a (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:5[2]:anon: - โ”‚ โ””โ”€โ”€ ยท35ee481 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:7: (main) - โ””โ”€โ”€ โ–บ:6[2]:D - โ””โ”€โ”€ ยทecb1877 (โŒ‚|1) - โ””โ”€โ”€ โ†’:7: (main) - +* ๐Ÿ‘‰ยท8a6c109 (โŒ‚) โ–บmerged[๐ŸŒณ] +โ”œโ”€โ•ฎ +* โ”‚ ยท62b409a (โŒ‚) โ–บA +โ”œโ”€โ”€โ”€โ•ฎ +* โ”‚ โ”‚ ยท592abec (โŒ‚) +โ”‚ โ”‚ * ยทf16dddf (โŒ‚) โ–บB +โ”œโ”€โ”€โ”€โ•ฏ +โ”‚ * ยท7ed512a (โŒ‚) โ–บC +โ”‚ โ”œโ”€โ•ฎ +โ”‚ * โ”‚ ยท35ee481 (โŒ‚) +โ”œโ”€โ•ฏ โ”‚ +โ”‚ * ยทecb1877 (โŒ‚) โ–บD +โ”œโ”€โ”€โ”€โ•ฏ +* ๐Ÿยท965998b (โŒ‚) โ–บmain "#]] ); + let stats = ws.statistics(); + assert_eq!(stats.commits, 8, "one commit per node"); assert_eq!( - graph.num_segments(), - 8, - "just as many as are displayed in the tree" - ); - assert_eq!(graph.num_commits(), 8, "one commit per node"); - assert_eq!( - graph.num_connections(), - 10, + stats.edges_connected, 10, "however, we see only a portion of the edges as the tree can only show simple stacks" ); snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" โŒ‚:merged[๐ŸŒณ] <> โœ“! โ””โ”€โ”€ โ‰ก:merged[๐ŸŒณ] {1} @@ -740,18 +523,18 @@ fn four_diamond() -> anyhow::Result<()> { } #[test] -fn explicit_traversal_tips_reject_duplicate_traversal_seeds() -> anyhow::Result<()> { +fn explicit_seeds_reject_duplicate_traversal_seeds() -> anyhow::Result<()> { let (repo, meta) = read_only_in_memory_scenario("four-diamond")?; let merged_id = id_by_rev(&repo, "merged").detach(); let a_id = id_by_rev(&repo, "A").detach(); let a_ref = ref_name("refs/heads/A"); - let err = Graph::from_commit_traversal_tips( + let err = Workspace::from_seeds( &repo, [ - Tip::entrypoint(merged_id, None), - Tip::reachable(a_id, None), - Tip::reachable(a_id, Some(a_ref)), + Seed::entrypoint(merged_id, None), + Seed::reachable(a_id, None), + Seed::reachable(a_id, Some(a_ref)), ], &*meta, but_core::ref_metadata::ProjectMeta::default(), @@ -761,24 +544,54 @@ fn explicit_traversal_tips_reject_duplicate_traversal_seeds() -> anyhow::Result< assert!( err.to_string() - .starts_with("explicit traversal tips contain duplicate traversal seed Tip"), + .starts_with("explicit traversal seeds contain duplicate traversal seed Seed"), "unexpected error: {err}" ); Ok(()) } #[test] -fn explicit_traversal_tips_allow_overlapping_commit_ids() -> anyhow::Result<()> { +fn explicit_seeds_allow_overlapping_commit_ids() -> anyhow::Result<()> { let (repo, meta) = read_only_in_memory_scenario("detached")?; let main_id = id_by_rev(&repo, "main").detach(); let main_ref = ref_name("refs/heads/main"); let release_tag = ref_name("refs/tags/release/v1"); - let graph = Graph::from_commit_traversal_tips( + let graph = Workspace::from_seeds( + &repo, + [ + Seed::entrypoint(main_id, Some(main_ref)), + Seed::reachable(main_id, Some(release_tag)), + ], + &*meta, + but_core::ref_metadata::ProjectMeta::default(), + standard_options(), + )? + .validated()?; + + snapbox::assert_data_eq!( + graph_dag(&graph), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท541396b (โŒ‚) โ–บmain, โ–บtags/annotated, โ–บtags/release/v1 +* ๐Ÿยทfafd9d0 (โŒ‚) โ–บother +"#]] + ); + Ok(()) +} + +#[test] +fn explicit_seeds_include_unnamed_revisions() -> anyhow::Result<()> { + let (repo, meta) = read_only_in_memory_scenario("four-diamond")?; + let merged_id = id_by_rev(&repo, "merged").detach(); + let a_id = id_by_rev(&repo, "A").detach(); + let c_id = id_by_rev(&repo, "C").detach(); + + let ws = Workspace::from_seeds( &repo, [ - Tip::entrypoint(main_id, Some(main_ref)), - Tip::reachable(main_id, Some(release_tag)), + Seed::entrypoint(merged_id, None), + Seed::reachable(a_id, None), + Seed::reachable(c_id, None), ], &*meta, but_core::ref_metadata::ProjectMeta::default(), @@ -787,23 +600,75 @@ fn explicit_traversal_tips_allow_overlapping_commit_ids() -> anyhow::Result<()> .validated()?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" +* ๐Ÿ‘‰ยท8a6c109 (โŒ‚) โ–บmerged[๐ŸŒณ] +โ”œโ”€โ•ฎ +* โ”‚ ยท62b409a (โŒ‚) โ–บA +โ”œโ”€โ”€โ”€โ•ฎ +* โ”‚ โ”‚ ยท592abec (โŒ‚) +โ”‚ โ”‚ * ยทf16dddf (โŒ‚) โ–บB +โ”œโ”€โ”€โ”€โ•ฏ +โ”‚ * ยท7ed512a (โŒ‚) โ–บC +โ”‚ โ”œโ”€โ•ฎ +โ”‚ * โ”‚ ยท35ee481 (โŒ‚) +โ”œโ”€โ•ฏ โ”‚ +โ”‚ * ยทecb1877 (โŒ‚) โ–บD +โ”œโ”€โ”€โ”€โ•ฏ +* ๐Ÿยท965998b (โŒ‚) โ–บmain +"#]] + ); + Ok(()) +} -โ””โ”€โ”€ โ–บ:0[0]:tags/release/v1 - โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:1[1]:main - โ””โ”€โ”€ ยท541396b (โŒ‚|1) โ–บtags/annotated, โ–บtags/release/v1 - โ””โ”€โ”€ โ–บ:2[2]:other - โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|1) +#[test] +fn explicit_traversal_prioritizes_integrated_tips_independent_of_input_order() -> anyhow::Result<()> +{ + let (repo, meta) = read_only_in_memory_scenario("four-diamond")?; + let merged_id = id_by_rev(&repo, "merged").detach(); + let a_id = id_by_rev(&repo, "A").detach(); + let main_id = id_by_rev(&repo, "main").detach(); + + // The integrated tip comes LAST from the caller; it must still be queued first + // so its flag propagates (main shows โœ“). + let ws = Workspace::from_seeds( + &repo, + [ + Seed::entrypoint(merged_id, None), + Seed::reachable(a_id, None), + Seed::integrated(main_id, None), + ], + &*meta, + but_core::ref_metadata::ProjectMeta::default(), + standard_options(), + )? + .validated()?; + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท8a6c109 (โŒ‚) โ–บmerged[๐ŸŒณ] +โ”œโ”€โ•ฎ +* โ”‚ ยท62b409a (โŒ‚) โ–บA +โ”œโ”€โ”€โ”€โ•ฎ +* โ”‚ โ”‚ ยท592abec (โŒ‚) +โ”‚ โ”‚ * ยทf16dddf (โŒ‚) โ–บB +โ”œโ”€โ”€โ”€โ•ฏ +โ”‚ * ยท7ed512a (โŒ‚) โ–บC +โ”‚ โ”œโ”€โ•ฎ +โ”‚ * โ”‚ ยท35ee481 (โŒ‚) +โ”œโ”€โ•ฏ โ”‚ +โ”‚ * ยทecb1877 (โŒ‚) โ–บD +โ”œโ”€โ”€โ”€โ•ฏ +* ๐Ÿยท965998b (โŒ‚|โœ“) โ–บmain "#]] ); Ok(()) } #[test] -fn explicit_traversal_tips_allow_named_and_anonymous_integrated_targets_on_same_commit() --> anyhow::Result<()> { +fn explicit_seeds_allow_named_and_anonymous_integrated_targets_on_same_commit() -> anyhow::Result<()> +{ let (repo, meta) = read_only_in_memory_scenario("four-diamond")?; let merged_id = id_by_rev(&repo, "merged").detach(); let main_id = id_by_rev(&repo, "main").detach(); @@ -830,12 +695,12 @@ fn explicit_traversal_tips_allow_named_and_anonymous_integrated_targets_on_same_ .raw() ); - let graph = Graph::from_commit_traversal_tips( + let graph = Workspace::from_seeds( &repo, [ - Tip::entrypoint(merged_id, Some(ref_name("refs/heads/merged"))), - Tip::integrated(main_id, Some(ref_name("refs/heads/main"))), - Tip::integrated(main_id, None), + Seed::entrypoint(merged_id, Some(ref_name("refs/heads/merged"))), + Seed::integrated(main_id, Some(ref_name("refs/heads/main"))), + Seed::integrated(main_id, None), ], &*meta, but_core::ref_metadata::ProjectMeta::default(), @@ -845,45 +710,38 @@ fn explicit_traversal_tips_allow_named_and_anonymous_integrated_targets_on_same_ // anonymous target context with the same commit collapses into the named target ref snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&graph), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:1[0]:merged[๐ŸŒณ] - โ””โ”€โ”€ ยท8a6c109 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:2[1]:A - โ”‚ โ””โ”€โ”€ ยท62b409a (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ โ–บ:4[2]:anon: - โ”‚ โ”‚ โ””โ”€โ”€ ยท592abec (โŒ‚|1) - โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:0[3]:main - โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท965998b (โŒ‚|โœ“|1) - โ”‚ โ””โ”€โ”€ โ–บ:5[2]:B - โ”‚ โ””โ”€โ”€ ยทf16dddf (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:0: (main) - โ””โ”€โ”€ โ–บ:3[1]:C - โ””โ”€โ”€ ยท7ed512a (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:6[2]:anon: - โ”‚ โ””โ”€โ”€ ยท35ee481 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:0: (main) - โ””โ”€โ”€ โ–บ:7[2]:D - โ””โ”€โ”€ ยทecb1877 (โŒ‚|1) - โ””โ”€โ”€ โ†’:0: (main) - +* ๐Ÿ‘‰ยท8a6c109 (โŒ‚) โ–บmerged[๐ŸŒณ] +โ”œโ”€โ•ฎ +* โ”‚ ยท62b409a (โŒ‚) โ–บA +โ”œโ”€โ”€โ”€โ•ฎ +* โ”‚ โ”‚ ยท592abec (โŒ‚) +โ”‚ โ”‚ * ยทf16dddf (โŒ‚) โ–บB +โ”œโ”€โ”€โ”€โ•ฏ +โ”‚ * ยท7ed512a (โŒ‚) โ–บC +โ”‚ โ”œโ”€โ•ฎ +โ”‚ * โ”‚ ยท35ee481 (โŒ‚) +โ”œโ”€โ•ฏ โ”‚ +โ”‚ * ยทecb1877 (โŒ‚) โ–บD +โ”œโ”€โ”€โ”€โ•ฏ +* ๐Ÿยท965998b (โŒ‚|โœ“) โ–บmain "#]] ); Ok(()) } #[test] -fn explicit_traversal_tips_reject_multiple_entrypoints() -> anyhow::Result<()> { +fn explicit_seeds_reject_multiple_entrypoints() -> anyhow::Result<()> { let (repo, meta) = read_only_in_memory_scenario("four-diamond")?; let merged_id = id_by_rev(&repo, "merged").detach(); let a_id = id_by_rev(&repo, "A").detach(); - let err = Graph::from_commit_traversal_tips( + let err = Workspace::from_seeds( &repo, [ - Tip::entrypoint(merged_id, None), - Tip::entrypoint(a_id, None), + Seed::entrypoint(merged_id, None), + Seed::entrypoint(a_id, None), ], &*meta, but_core::ref_metadata::ProjectMeta::default(), @@ -893,23 +751,23 @@ fn explicit_traversal_tips_reject_multiple_entrypoints() -> anyhow::Result<()> { assert_eq!( err.to_string(), - "explicit traversal tips require exactly one entrypoint" + "explicit traversal seeds require exactly one entrypoint" ); Ok(()) } #[test] -fn explicit_traversal_tips_reject_duplicate_ref_names() -> anyhow::Result<()> { +fn explicit_seeds_reject_duplicate_ref_names() -> anyhow::Result<()> { let (repo, meta) = read_only_in_memory_scenario("four-diamond")?; let a_id = id_by_rev(&repo, "A").detach(); let c_id = id_by_rev(&repo, "C").detach(); let a_ref = ref_name("refs/heads/A"); - let err = Graph::from_commit_traversal_tips( + let err = Workspace::from_seeds( &repo, [ - Tip::entrypoint(a_id, Some(a_ref.clone())), - Tip::reachable(c_id, Some(a_ref.clone())), + Seed::entrypoint(a_id, Some(a_ref.clone())), + Seed::reachable(c_id, Some(a_ref.clone())), ], &*meta, but_core::ref_metadata::ProjectMeta::default(), @@ -919,19 +777,19 @@ fn explicit_traversal_tips_reject_duplicate_ref_names() -> anyhow::Result<()> { assert_eq!( err.to_string(), - format!("explicit traversal tips contain duplicate ref name {a_ref}") + format!("explicit traversal seeds contain duplicate ref name {a_ref}") ); Ok(()) } #[test] -fn explicit_traversal_tips_reject_detached_entrypoint_with_ref_name() -> anyhow::Result<()> { +fn explicit_seeds_reject_detached_entrypoint_with_ref_name() -> anyhow::Result<()> { let (repo, meta) = read_only_in_memory_scenario("four-diamond")?; let merged_id = id_by_rev(&repo, "merged").detach(); - let err = Graph::from_commit_traversal_tips( + let err = Workspace::from_seeds( &repo, - [Tip::new(merged_id) + [Seed::new(merged_id) .with_ref_name(Some(ref_name("refs/heads/merged"))) .with_entrypoint() .with_is_detached(true)], @@ -943,21 +801,21 @@ fn explicit_traversal_tips_reject_detached_entrypoint_with_ref_name() -> anyhow: assert_eq!( err.to_string(), - "explicit detached entrypoint tip cannot have a ref name" + "explicit detached entrypoint seed cannot have a ref name" ); Ok(()) } #[test] -fn explicit_traversal_tips_reject_ref_names_that_point_elsewhere() -> anyhow::Result<()> { +fn explicit_seeds_reject_ref_names_that_point_elsewhere() -> anyhow::Result<()> { let (repo, meta) = read_only_in_memory_scenario("four-diamond")?; let merged_id = id_by_rev(&repo, "merged").detach(); let a_id = id_by_rev(&repo, "A").detach(); let a_ref = ref_name("refs/heads/A"); - let err = Graph::from_commit_traversal_tips( + let err = Workspace::from_seeds( &repo, - [Tip::entrypoint(merged_id, Some(a_ref.clone()))], + [Seed::entrypoint(merged_id, Some(a_ref.clone()))], &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), @@ -966,7 +824,7 @@ fn explicit_traversal_tips_reject_ref_names_that_point_elsewhere() -> anyhow::Re assert_eq!( err.to_string(), - format!("explicit traversal tip ref {a_ref} points to {a_id}, not {merged_id}") + format!("explicit traversal seed ref {a_ref} points to {a_id}, not {merged_id}") ); Ok(()) } @@ -978,7 +836,7 @@ fn traversal_entrypoint_ref_override_must_point_to_entrypoint() -> anyhow::Resul let a_id = id_by_rev(&repo, "A").detach(); let a_ref = ref_name("refs/heads/A"); - let err = Graph::from_commit_traversal( + let err = Workspace::from_tip( id_by_rev(&repo, "merged"), Some(a_ref.clone()), &*meta, @@ -995,7 +853,7 @@ fn traversal_entrypoint_ref_override_must_point_to_entrypoint() -> anyhow::Resul } #[test] -fn explicit_traversal_tips_use_integrated_tip_as_workspace_target_commit() -> anyhow::Result<()> { +fn explicit_seeds_use_integrated_tip_as_workspace_target_commit() -> anyhow::Result<()> { let (repo, meta) = read_only_in_memory_scenario("four-diamond")?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -1023,12 +881,12 @@ fn explicit_traversal_tips_use_integrated_tip_as_workspace_target_commit() -> an let target_ref_name = ref_name("refs/heads/A"); let target_ref_id = id_by_rev(&repo, "A").detach(); let target_commit_id = id_by_rev(&repo, "main").detach(); - let graph = Graph::from_commit_traversal_tips( + let ws = Workspace::from_seeds( &repo, [ - Tip::entrypoint(merged_id, Some(ref_name("refs/heads/merged"))), - Tip::integrated(target_ref_id, Some(target_ref_name.clone())), - Tip::integrated(target_commit_id, None), + Seed::entrypoint(merged_id, Some(ref_name("refs/heads/merged"))), + Seed::integrated(target_ref_id, Some(target_ref_name.clone())), + Seed::integrated(target_commit_id, None), ], &*meta, but_core::ref_metadata::ProjectMeta::default(), @@ -1036,40 +894,30 @@ fn explicit_traversal_tips_use_integrated_tip_as_workspace_target_commit() -> an )? .validated()?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:2[0]:merged[๐ŸŒณ] - โ””โ”€โ”€ ยท8a6c109 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:0[1]:A - โ”‚ โ””โ”€โ”€ ยท62b409a (โŒ‚|โœ“|1) - โ”‚ โ”œโ”€โ”€ โ–บ:3[2]:anon: - โ”‚ โ”‚ โ””โ”€โ”€ ยท592abec (โŒ‚|โœ“|1) - โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:1[3]:main - โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท965998b (โŒ‚|โœ“|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[2]:B - โ”‚ โ””โ”€โ”€ ยทf16dddf (โŒ‚|โœ“|1) - โ”‚ โ””โ”€โ”€ โ†’:1: (main) - โ””โ”€โ”€ โ–บ:5[1]:C - โ””โ”€โ”€ ยท7ed512a (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:6[2]:anon: - โ”‚ โ””โ”€โ”€ ยท35ee481 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:1: (main) - โ””โ”€โ”€ โ–บ:7[2]:D - โ””โ”€โ”€ ยทecb1877 (โŒ‚|1) - โ””โ”€โ”€ โ†’:1: (main) - +* ๐Ÿ‘‰ยท8a6c109 (โŒ‚) โ–บmerged[๐ŸŒณ] +โ”œโ”€โ•ฎ +* โ”‚ ยท62b409a (โŒ‚|โœ“) โ–บA +โ”œโ”€โ”€โ”€โ•ฎ +* โ”‚ โ”‚ ยท592abec (โŒ‚|โœ“) +โ”‚ โ”‚ * ยทf16dddf (โŒ‚|โœ“) โ–บB +โ”œโ”€โ”€โ”€โ•ฏ +โ”‚ * ยท7ed512a (โŒ‚) โ–บC +โ”‚ โ”œโ”€โ•ฎ +โ”‚ * โ”‚ ยท35ee481 (โŒ‚) +โ”œโ”€โ•ฏ โ”‚ +โ”‚ * ยทecb1877 (โŒ‚) โ–บD +โ”œโ”€โ”€โ”€โ•ฏ +* ๐Ÿยท965998b (โŒ‚|โœ“) โ–บmain "#]] ); - let target_segment = graph.segment_by_commit_id(target_commit_id)?; - assert_eq!( - target_segment.commits.first().map(|commit| commit.id), - Some(target_commit_id), - "integrated tip is also split into its own segment" + assert!( + ws.commit_graph().node(target_commit_id).is_some(), + "the integrated tip made it into the graph; the DAG above shows it as its own run" ); - let ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1115,7 +963,7 @@ 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( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), @@ -1123,27 +971,20 @@ fn stacked_rebased_remotes() -> anyhow::Result<()> { )? .validated()?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:B[๐ŸŒณ] <> origin/B โ†’:1: -โ”‚ โ””โ”€โ”€ ยท312f819 (โŒ‚|0001) -โ”‚ โ””โ”€โ”€ โ–บ:2[1]:A <> origin/A โ†’:3: -โ”‚ โ””โ”€โ”€ ยทe255adc (โŒ‚|0101) -โ”‚ โ””โ”€โ”€ โ–บ:4[2]:main -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|1111) -โ””โ”€โ”€ โ–บ:1[0]:origin/B โ†’:0: - โ””โ”€โ”€ ๐ŸŸฃ682be32 (0x0|0010) - โ””โ”€โ”€ โ–บ:3[1]:origin/A โ†’:2: - โ””โ”€โ”€ ๐ŸŸฃe29c23d (0x0|1010) - โ””โ”€โ”€ โ†’:4: (main) - +* ๐Ÿ‘‰ยท312f819 (โŒ‚) โ–บB[๐ŸŒณ] <> origin/B +* ยทe255adc (โŒ‚) โ–บA <> origin/A +โ”‚ * ๐ŸŸฃ682be32 โ–บorigin/B +โ”‚ * ๐ŸŸฃe29c23d โ–บorigin/A +โ”œโ”€โ•ฏ +* ๐Ÿยทfafd9d0 (โŒ‚) โ–บmain "#]] ); // 'main' is frozen because it connects to a 'foreign' remote, the commit was pushed. snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" โŒ‚:B[๐ŸŒณ] <> โœ“refs/remotes/origin/Bโ‡ฃ2 on fafd9d0 โ””โ”€โ”€ โ‰ก:B[๐ŸŒณ] <> origin/Bโ‡ก1โ‡ฃ1 on fafd9d0 {1} @@ -1159,7 +1000,7 @@ fn stacked_rebased_remotes() -> anyhow::Result<()> { // The hard limit stops queueing deeper commits, but queued commits are still processed // so existing work can complete its graph connections. - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), @@ -1167,40 +1008,37 @@ fn stacked_rebased_remotes() -> anyhow::Result<()> { )? .validated()?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:B[๐ŸŒณ] <> origin/B โ†’:1: -โ”‚ โ””โ”€โ”€ ยท312f819 (โŒ‚|001) -โ”‚ โ””โ”€โ”€ โ–บ:2[1]:A <> origin/A โ†’:5: -โ”‚ โ””โ”€โ”€ โŒยทe255adc (โŒ‚|101) -โ”œโ”€โ”€ โ–บ:1[0]:origin/B โ†’:0: -โ”‚ โ””โ”€โ”€ ๐ŸŸฃ682be32 (0x0|010) -โ”‚ โ””โ”€โ”€ โ–บ:5[1]:origin/A โ†’:2: -โ”‚ โ””โ”€โ”€ ๐ŸŸฃe29c23d (0x0|010) -โ”‚ โ””โ”€โ”€ โ–บ:4[2]:main -โ”‚ โ””โ”€โ”€ ๐Ÿ๐ŸŸฃfafd9d0 (0x0|010) -โ””โ”€โ”€ โ–บ:3[0]:origin/A - +* ๐Ÿ‘‰ยท312f819 (โŒ‚) โ–บB[๐ŸŒณ] <> origin/B +* โŒยทe255adc (โŒ‚) โ–บA <> origin/A +* ๐ŸŸฃ682be32 โ–บorigin/B +* ๐ŸŸฃe29c23d โ–บorigin/A +* ๐Ÿ๐ŸŸฃfafd9d0 โ–บmain "#]] ); assert!( - graph.hard_limit_hit(), + ws.hard_limit_hit(), "graph should record that traversal stopped queueing after hitting the hard limit" ); snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:B[๐ŸŒณ] <> โœ“refs/remotes/origin/Bโ‡ฃ1 on 312f819 -โ””โ”€โ”€ โ‰ก:B[๐ŸŒณ] <> origin/Bโ‡ฃ1 on e255adc {1} - โ””โ”€โ”€ :B[๐ŸŒณ] <> origin/Bโ‡ฃ1 - โ””โ”€โ”€ ๐ŸŸฃ682be32 +โŒ‚:B[๐ŸŒณ] <> โœ“refs/remotes/origin/Bโ‡ฃ2 on fafd9d0 +โ””โ”€โ”€ โ‰ก:B[๐ŸŒณ] <> origin/Bโ‡ก1โ‡ฃ1 {1} + โ”œโ”€โ”€ :B[๐ŸŒณ] <> origin/Bโ‡ก1โ‡ฃ1 + โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ682be32 + โ”‚ โ””โ”€โ”€ ยท312f819 + โ””โ”€โ”€ :A <> origin/Aโ‡ก1โ‡ฃ2 + โ”œโ”€โ”€ ๐ŸŸฃe29c23d + โ”œโ”€โ”€ ๐ŸŸฃfafd9d0 + โ””โ”€โ”€ โŒยทe255adc "#]] ); // Everything we encounter is checked for remotes (no limit) - let graph = Graph::from_head( + let graph = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), @@ -1208,27 +1046,20 @@ fn stacked_rebased_remotes() -> anyhow::Result<()> { )? .validated()?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&graph), snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:B[๐ŸŒณ] <> origin/B โ†’:1: -โ”‚ โ””โ”€โ”€ ยท312f819 (โŒ‚|0001) -โ”‚ โ””โ”€โ”€ โ–บ:2[1]:A <> origin/A โ†’:3: -โ”‚ โ””โ”€โ”€ ยทe255adc (โŒ‚|0101) -โ”‚ โ””โ”€โ”€ โ–บ:4[2]:main -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|1111) -โ””โ”€โ”€ โ–บ:1[0]:origin/B โ†’:0: - โ””โ”€โ”€ ๐ŸŸฃ682be32 (0x0|0010) - โ””โ”€โ”€ โ–บ:3[1]:origin/A โ†’:2: - โ””โ”€โ”€ ๐ŸŸฃe29c23d (0x0|1010) - โ””โ”€โ”€ โ†’:4: (main) - +* ๐Ÿ‘‰ยท312f819 (โŒ‚) โ–บB[๐ŸŒณ] <> origin/B +* ยทe255adc (โŒ‚) โ–บA <> origin/A +โ”‚ * ๐ŸŸฃ682be32 โ–บorigin/B +โ”‚ * ๐ŸŸฃe29c23d โ–บorigin/A +โ”œโ”€โ•ฏ +* ๐Ÿยทfafd9d0 (โŒ‚) โ–บmain "#]] ); // With a lower entrypoint, we don't see part of the graph. let (id, name) = id_at(&repo, "A"); - let graph = Graph::from_commit_traversal( + let ws = Workspace::from_tip( id, name, &*meta, @@ -1237,21 +1068,16 @@ fn stacked_rebased_remotes() -> anyhow::Result<()> { )? .validated()?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:A <> origin/A โ†’:1: -โ”‚ โ””โ”€โ”€ ยทe255adc (โŒ‚|01) -โ”‚ โ””โ”€โ”€ โ–บ:2[1]:main -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|11) -โ””โ”€โ”€ โ–บ:1[0]:origin/A โ†’:0: - โ””โ”€โ”€ ๐ŸŸฃe29c23d (0x0|10) - โ””โ”€โ”€ โ†’:2: (main) - +* ๐Ÿ‘‰ยทe255adc (โŒ‚) โ–บA <> origin/A +โ”‚ * ๐ŸŸฃe29c23d โ–บorigin/A +โ”œโ”€โ•ฏ +* ๐Ÿยทfafd9d0 (โŒ‚) โ–บmain "#]] ); snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" โŒ‚:A <> โœ“refs/remotes/origin/Aโ‡ฃ1 on fafd9d0 โ””โ”€โ”€ โ‰ก:A <> origin/Aโ‡ก1โ‡ฃ1 on fafd9d0 {1} @@ -1294,7 +1120,7 @@ fn with_limits() -> anyhow::Result<()> { ); // Without limits - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), @@ -1302,37 +1128,31 @@ fn with_limits() -> anyhow::Result<()> { )? .validated()?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:C[๐ŸŒณ] - โ””โ”€โ”€ ยท2a95729 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:anon: - โ”‚ โ”œโ”€โ”€ ยท6861158 (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ ยท4f1f248 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ ยท487ffce (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[2]:main - โ”‚ โ”œโ”€โ”€ ยทedc4dee (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ ยท01d0e1e (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ ยท4b3e5a8 (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ ยท34d0715 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ ๐Ÿยทeb5f731 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:2[1]:A - โ”‚ โ”œโ”€โ”€ ยท20a823c (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ ยท442a12f (โŒ‚|1) - โ”‚ โ””โ”€โ”€ ยท686706b (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:4: (main) - โ””โ”€โ”€ โ–บ:3[1]:B - โ”œโ”€โ”€ ยท9908c99 (โŒ‚|1) - โ”œโ”€โ”€ ยท60d9a56 (โŒ‚|1) - โ””โ”€โ”€ ยท9d171ff (โŒ‚|1) - โ””โ”€โ”€ โ†’:4: (main) - + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท2a95729 (โŒ‚) โ–บC[๐ŸŒณ] +โ”œโ”€โ”ฌโ”€โ•ฎ +* โ”‚ โ”‚ ยท6861158 (โŒ‚) +* โ”‚ โ”‚ ยท4f1f248 (โŒ‚) +* โ”‚ โ”‚ ยท487ffce (โŒ‚) +โ”‚ * โ”‚ ยท20a823c (โŒ‚) โ–บA +โ”‚ * โ”‚ ยท442a12f (โŒ‚) +โ”‚ * โ”‚ ยท686706b (โŒ‚) +โ”œโ”€โ•ฏ โ”‚ +โ”‚ * ยท9908c99 (โŒ‚) โ–บB +โ”‚ * ยท60d9a56 (โŒ‚) +โ”‚ * ยท9d171ff (โŒ‚) +โ”œโ”€โ”€โ”€โ•ฏ +* ยทedc4dee (โŒ‚) โ–บmain +* ยท01d0e1e (โŒ‚) +* ยท4b3e5a8 (โŒ‚) +* ยท34d0715 (โŒ‚) +* ๐Ÿยทeb5f731 (โŒ‚) "#]] ); // No limits list the first parent everywhere. snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" โŒ‚:C[๐ŸŒณ] <> โœ“! โ””โ”€โ”€ โ‰ก:C[๐ŸŒณ] {1} @@ -1353,25 +1173,17 @@ 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( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options().with_limit_hint(0), )? .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:C[๐ŸŒณ] - โ””โ”€โ”€ โœ‚ยท2a95729 (โŒ‚|1) - -"#]] - ); + snapbox::assert_data_eq!(graph_dag(&ws), snapbox::str!["* ๐Ÿ‘‰โœ‚ยท2a95729 (โŒ‚) โ–บC[๐ŸŒณ]"]); // The cut by limit is also represented here. snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" โŒ‚:C[๐ŸŒณ] <> โœ“! โ””โ”€โ”€ โ‰ก:C[๐ŸŒณ] {1} @@ -1382,7 +1194,7 @@ fn with_limits() -> anyhow::Result<()> { ); // A single commit, the merge commit. - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), @@ -1390,22 +1202,17 @@ fn with_limits() -> anyhow::Result<()> { )? .validated()?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:C[๐ŸŒณ] - โ””โ”€โ”€ ยท2a95729 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:anon: - โ”‚ โ””โ”€โ”€ โœ‚ยท6861158 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:2[1]:A - โ”‚ โ””โ”€โ”€ โœ‚ยท20a823c (โŒ‚|1) - โ””โ”€โ”€ โ–บ:3[1]:B - โ””โ”€โ”€ โœ‚ยท9908c99 (โŒ‚|1) - +* ๐Ÿ‘‰ยท2a95729 (โŒ‚) โ–บC[๐ŸŒณ] +โ”œโ”€โ”ฌโ”€โ•ฎ +* โ”‚ โ”‚ โœ‚ยท6861158 (โŒ‚) + * โ”‚ โœ‚ยท20a823c (โŒ‚) โ–บA + * โœ‚ยท9908c99 (โŒ‚) โ–บB "#]] ); snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" โŒ‚:C[๐ŸŒณ] <> โœ“! โ””โ”€โ”€ โ‰ก:C[๐ŸŒณ] {1} @@ -1418,7 +1225,7 @@ fn with_limits() -> anyhow::Result<()> { // Hitting the hard limit while queueing merge parents still queues the // complete parent set. The hard limit only prevents traversal beyond them. - let graph = Graph::from_head( + let graph = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), @@ -1430,23 +1237,18 @@ fn with_limits() -> anyhow::Result<()> { "graph should record that traversal stopped queueing after hitting the hard limit" ); snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&graph), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:C[๐ŸŒณ] - โ””โ”€โ”€ ยท2a95729 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:anon: - โ”‚ โ””โ”€โ”€ โŒยท6861158 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:2[1]:A - โ”‚ โ””โ”€โ”€ โŒยท20a823c (โŒ‚|1) - โ””โ”€โ”€ โ–บ:3[1]:B - โ””โ”€โ”€ โŒยท9908c99 (โŒ‚|1) - +* ๐Ÿ‘‰ยท2a95729 (โŒ‚) โ–บC[๐ŸŒณ] +โ”œโ”€โ”ฌโ”€โ•ฎ +* โ”‚ โ”‚ โŒยท6861158 (โŒ‚) + * โ”‚ โŒยท20a823c (โŒ‚) โ–บA + * โŒยท9908c99 (โŒ‚) โ–บB "#]] ); // The merge commit, then we witness lane-duplication of the limit so we get more than requested. - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), @@ -1454,25 +1256,20 @@ fn with_limits() -> anyhow::Result<()> { )? .validated()?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:C[๐ŸŒณ] - โ””โ”€โ”€ ยท2a95729 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:anon: - โ”‚ โ”œโ”€โ”€ ยท6861158 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โœ‚ยท4f1f248 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:2[1]:A - โ”‚ โ”œโ”€โ”€ ยท20a823c (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โœ‚ยท442a12f (โŒ‚|1) - โ””โ”€โ”€ โ–บ:3[1]:B - โ”œโ”€โ”€ ยท9908c99 (โŒ‚|1) - โ””โ”€โ”€ โœ‚ยท60d9a56 (โŒ‚|1) - +* ๐Ÿ‘‰ยท2a95729 (โŒ‚) โ–บC[๐ŸŒณ] +โ”œโ”€โ”ฌโ”€โ•ฎ +* โ”‚ โ”‚ ยท6861158 (โŒ‚) +* โ”‚ โ”‚ โœ‚ยท4f1f248 (โŒ‚) + * โ”‚ ยท20a823c (โŒ‚) โ–บA + * โ”‚ โœ‚ยท442a12f (โŒ‚) + * ยท9908c99 (โŒ‚) โ–บB + * โœ‚ยท60d9a56 (โŒ‚) "#]] ); snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" โŒ‚:C[๐ŸŒณ] <> โœ“! โ””โ”€โ”€ โ‰ก:C[๐ŸŒณ] {1} @@ -1486,7 +1283,7 @@ fn with_limits() -> anyhow::Result<()> { // Allow to see more commits just in the middle lane, the limit is reset, // and we see two more. - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), @@ -1496,26 +1293,21 @@ fn with_limits() -> anyhow::Result<()> { )? .validated()?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:C[๐ŸŒณ] - โ””โ”€โ”€ ยท2a95729 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:anon: - โ”‚ โ”œโ”€โ”€ ยท6861158 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โœ‚ยท4f1f248 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:2[1]:A - โ”‚ โ”œโ”€โ”€ ยท20a823c (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ ยท442a12f (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โœ‚ยท686706b (โŒ‚|1) - โ””โ”€โ”€ โ–บ:3[1]:B - โ”œโ”€โ”€ ยท9908c99 (โŒ‚|1) - โ””โ”€โ”€ โœ‚ยท60d9a56 (โŒ‚|1) - +* ๐Ÿ‘‰ยท2a95729 (โŒ‚) โ–บC[๐ŸŒณ] +โ”œโ”€โ”ฌโ”€โ•ฎ +* โ”‚ โ”‚ ยท6861158 (โŒ‚) +* โ”‚ โ”‚ โœ‚ยท4f1f248 (โŒ‚) + * โ”‚ ยท20a823c (โŒ‚) โ–บA + * โ”‚ ยท442a12f (โŒ‚) + * โ”‚ โœ‚ยท686706b (โŒ‚) + * ยท9908c99 (โŒ‚) โ–บB + * โœ‚ยท60d9a56 (โŒ‚) "#]] ); snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" โŒ‚:C[๐ŸŒณ] <> โœ“! โ””โ”€โ”€ โ‰ก:C[๐ŸŒณ] {1} @@ -1529,7 +1321,7 @@ fn with_limits() -> anyhow::Result<()> { // Multiple extensions are fine as well. let id = |rev| id_by_rev(&repo, rev).detach(); - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), @@ -1539,83 +1331,49 @@ fn with_limits() -> anyhow::Result<()> { )? .validated()?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:C[๐ŸŒณ] - โ””โ”€โ”€ ยท2a95729 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:anon: - โ”‚ โ”œโ”€โ”€ ยท6861158 (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ ยท4f1f248 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โœ‚ยท487ffce (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:2[1]:A - โ”‚ โ”œโ”€โ”€ ยท20a823c (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ ยท442a12f (โŒ‚|1) - โ”‚ โ””โ”€โ”€ ยท686706b (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[2]:main - โ”‚ โ”œโ”€โ”€ ยทedc4dee (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โœ‚ยท01d0e1e (โŒ‚|1) - โ””โ”€โ”€ โ–บ:3[1]:B - โ”œโ”€โ”€ ยท9908c99 (โŒ‚|1) - โ”œโ”€โ”€ ยท60d9a56 (โŒ‚|1) - โ””โ”€โ”€ โœ‚ยท9d171ff (โŒ‚|1) - -"#]] - ); - snapbox::assert_data_eq!( - graph.statistics().to_debug(), - snapbox::str![[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, + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท2a95729 (โŒ‚) โ–บC[๐ŸŒณ] +โ”œโ”€โ”ฌโ”€โ•ฎ +* โ”‚ โ”‚ ยท6861158 (โŒ‚) +* โ”‚ โ”‚ ยท4f1f248 (โŒ‚) +* โ”‚ โ”‚ โœ‚ยท487ffce (โŒ‚) + * โ”‚ ยท20a823c (โŒ‚) โ–บA + * โ”‚ ยท442a12f (โŒ‚) + * โ”‚ ยท686706b (โŒ‚) + * โ”‚ ยทedc4dee (โŒ‚) โ–บmain + * โ”‚ โœ‚ยท01d0e1e (โŒ‚) + * ยท9908c99 (โŒ‚) โ–บB + * ยท60d9a56 (โŒ‚) + * โœ‚ยท9d171ff (โŒ‚) +"#]] + ); + snapbox::assert_data_eq!( + format!("{:#?}", ws.statistics()).as_str(), + snapbox::str![[r#" +CommitGraphStatistics { + commits: 12, + edges_connected: 11, + edges_cut: 0, + refs: 4, + commits_at_tip: 1, + commits_at_bottom: 3, + commits_in_workspace: 0, + commits_integrated: 0, + commits_not_in_remote: 12, + layout_refs: Some( + 4, ), - segments_behind_of_entrypoint: 4, - segments_ahead_of_entrypoint: 0, - entrypoint: ( - NodeIndex(0), - Some( - 0, - ), + hard_limit_hit: false, + entrypoint: Some( + Sha1(2a957298aaca646cc5e1d0bfebbc9840e7568c78), ), - segment_entrypoint_incoming: 0, - segment_entrypoint_outgoing: 3, - top_segments: [ - ( - Some( - FullName( - "refs/heads/C", - ), - ), - NodeIndex(0), - Some( - CommitFlags( - NotInRemote | 0x10, - ), - ), - ), - ], - segments_at_bottom: 3, - connections: 4, - commits: 12, - commit_references: 0, - commits_at_cutoff: 3, } - "#]] ); snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" โŒ‚:C[๐ŸŒณ] <> โœ“! โ””โ”€โ”€ โ‰ก:C[๐ŸŒณ] {1} @@ -1629,7 +1387,7 @@ Statistics { ); // We can specify any target, despite not having a workspace setup. - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), @@ -1639,37 +1397,31 @@ Statistics { // This limits the reach of the stack naturally. snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:C[๐ŸŒณ] - โ””โ”€โ”€ ยท2a95729 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:2[1]:anon: - โ”‚ โ”œโ”€โ”€ ยท6861158 (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ ยท4f1f248 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ ยท487ffce (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:1[2]:main - โ”‚ โ”œโ”€โ”€ ยทedc4dee (โŒ‚|โœ“|1) - โ”‚ โ”œโ”€โ”€ ยท01d0e1e (โŒ‚|โœ“|1) - โ”‚ โ”œโ”€โ”€ ยท4b3e5a8 (โŒ‚|โœ“|1) - โ”‚ โ”œโ”€โ”€ ยท34d0715 (โŒ‚|โœ“|1) - โ”‚ โ””โ”€โ”€ ๐Ÿยทeb5f731 (โŒ‚|โœ“|1) - โ”œโ”€โ”€ โ–บ:3[1]:A - โ”‚ โ”œโ”€โ”€ ยท20a823c (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ ยท442a12f (โŒ‚|1) - โ”‚ โ””โ”€โ”€ ยท686706b (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:1: (main) - โ””โ”€โ”€ โ–บ:4[1]:B - โ”œโ”€โ”€ ยท9908c99 (โŒ‚|1) - โ”œโ”€โ”€ ยท60d9a56 (โŒ‚|1) - โ””โ”€โ”€ ยท9d171ff (โŒ‚|1) - โ””โ”€โ”€ โ†’:1: (main) - +* ๐Ÿ‘‰ยท2a95729 (โŒ‚) โ–บC[๐ŸŒณ] +โ”œโ”€โ”ฌโ”€โ•ฎ +* โ”‚ โ”‚ ยท6861158 (โŒ‚) +* โ”‚ โ”‚ ยท4f1f248 (โŒ‚) +* โ”‚ โ”‚ ยท487ffce (โŒ‚) +โ”‚ * โ”‚ ยท20a823c (โŒ‚) โ–บA +โ”‚ * โ”‚ ยท442a12f (โŒ‚) +โ”‚ * โ”‚ ยท686706b (โŒ‚) +โ”œโ”€โ•ฏ โ”‚ +โ”‚ * ยท9908c99 (โŒ‚) โ–บB +โ”‚ * ยท60d9a56 (โŒ‚) +โ”‚ * ยท9d171ff (โŒ‚) +โ”œโ”€โ”€โ”€โ•ฏ +* ยทedc4dee (โŒ‚|โœ“) โ–บmain +* ยท01d0e1e (โŒ‚|โœ“) +* ยท4b3e5a8 (โŒ‚|โœ“) +* ยท34d0715 (โŒ‚|โœ“) +* ๐Ÿยทeb5f731 (โŒ‚|โœ“) "#]] ); snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" โŒ‚:C[๐ŸŒณ] <> โœ“! on edc4dee โ””โ”€โ”€ โ‰ก:C[๐ŸŒณ] on edc4dee {1} @@ -1697,7 +1449,7 @@ fn special_branch_names_do_not_end_up_in_segment() -> anyhow::Result<()> { "#]] ); - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), @@ -1706,22 +1458,17 @@ fn special_branch_names_do_not_end_up_in_segment() -> anyhow::Result<()> { .validated()?; // Standard handling after travrsal and post-processing. snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:main[๐ŸŒณ] - โ””โ”€โ”€ ยท3686017 (โŒ‚|1) - โ””โ”€โ”€ โ–บ:1[1]:gitbutler/edit - โ””โ”€โ”€ ยท9725482 (โŒ‚|1) - โ””โ”€โ”€ โ–บ:2[2]:gitbutler/target - โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|1) - +* ๐Ÿ‘‰ยท3686017 (โŒ‚) โ–บmain[๐ŸŒณ] +* ยท9725482 (โŒ‚) โ–บgitbutler/edit +* ๐Ÿยทfafd9d0 (โŒ‚) โ–บgitbutler/target "#]] ); // But special handling for workspace views. snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" โŒ‚:main[๐ŸŒณ] <> โœ“! โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] {1} @@ -1746,7 +1493,7 @@ fn ambiguous_worktrees() -> anyhow::Result<()> { "#]] ); - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), @@ -1754,17 +1501,14 @@ fn ambiguous_worktrees() -> anyhow::Result<()> { )? .validated()?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:main[๐ŸŒณ@repo] - โ””โ”€โ”€ ๐Ÿยท85efbe4 (โŒ‚|1) โ–บwt-inside-ambiguous-worktree[๐Ÿ“], โ–บwt-outside-ambiguous-worktree[๐Ÿ“] - -"#]] + graph_dag(&ws), + snapbox::str![ + "* ๐Ÿ‘‰๐Ÿยท85efbe4 (โŒ‚) โ–บmain[๐ŸŒณ@repo], โ–บwt-inside-ambiguous-worktree[๐Ÿ“], โ–บwt-outside-ambiguous-worktree[๐Ÿ“]" + ] ); snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" โŒ‚:main[๐ŸŒณ@repo] <> โœ“! โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ@repo] {1} @@ -1782,7 +1526,7 @@ fn ambiguous_worktrees() -> anyhow::Result<()> { gix::open::Options::isolated(), )? .with_object_memory(); - let graph = Graph::from_head( + let ws = Workspace::from_head( &linked_repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), @@ -1791,18 +1535,15 @@ fn ambiguous_worktrees() -> anyhow::Result<()> { .validated()?; // when the graph is built from the linked worktree repository, it can't see anything else without metadata snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:wt-inside-ambiguous-worktree[๐Ÿ“@repo] - โ””โ”€โ”€ ๐Ÿยท85efbe4 (โŒ‚|1) โ–บmain[๐ŸŒณ], โ–บwt-outside-ambiguous-worktree[๐Ÿ“] - -"#]] + graph_dag(&ws), + snapbox::str![ + "* ๐Ÿ‘‰๐Ÿยท85efbe4 (โŒ‚) โ–บmain[๐ŸŒณ], โ–บwt-inside-ambiguous-worktree[๐Ÿ“@repo], โ–บwt-outside-ambiguous-worktree[๐Ÿ“]" + ] ); // workspace debug output should preserve that the linked worktree, not the main worktree, is owned by the repository used to build the graph snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" โŒ‚:wt-inside-ambiguous-worktree[๐Ÿ“@repo] <> โœ“! โ””โ”€โ”€ โ‰ก:wt-inside-ambiguous-worktree[๐Ÿ“@repo] {1} @@ -1857,24 +1598,20 @@ fn commit_with_two_parents() -> anyhow::Result<()> { ); let meta = in_memory_meta(tmp.as_ref())?; - let graph = Graph::from_head( + let graph = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - // Duplicate parent commits are kept verbatim. + // Duplicate parent commits are collapsed at read time: lanes come from workspace metadata, not + // repeated parent entries, so `[base, base]` becomes a single edge and the history stays linear. snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&graph), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:main[๐ŸŒณ] - โ””โ”€โ”€ ยท06470d7 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:anon: - โ”‚ โ””โ”€โ”€ ๐Ÿยท86719d5 (โŒ‚|1) - โ””โ”€โ”€ โ†’:1: - +* ๐Ÿ‘‰ยท06470d7 (โŒ‚) โ–บmain[๐ŸŒณ] +* ๐Ÿยท86719d5 (โŒ‚) "#]] ); Ok(()) @@ -1887,7 +1624,7 @@ fn ad_hoc_same_tip_order_creates_empty_branch_segments() -> anyhow::Result<()> { create_branches(&repo, tip, ["refs/heads/top", "refs/heads/bottom"])?; let meta = in_memory_meta(tmp.as_ref())?; - let graph = graph_with_branch_order( + let ws = workspace_with_branch_order( &repo, &*meta, "refs/heads/top", @@ -1896,22 +1633,20 @@ fn ad_hoc_same_tip_order_creates_empty_branch_segments() -> anyhow::Result<()> { .validated()?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:1[0]:top - โ””โ”€โ”€ โ–บ:0[1]:bottom - โ””โ”€โ”€ ๐Ÿยท960152d (โŒ‚|1) โ–บmain[๐ŸŒณ] - +* ๐Ÿ‘‰๐Ÿยท960152d (โŒ‚) โ–บbottom, โ–บmain[๐ŸŒณ], โ–บtop +layout: + empty chain anchors: 960152d^ "#]] ); assert_eq!( - graph.entrypoint()?.commit().map(|commit| commit.id), + ws.entrypoint_commit_id()?, Some(tip), "a checked-out empty ordered branch still points at the bottom commit" ); snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" โŒ‚:top <> โœ“! โ””โ”€โ”€ โ‰ก:top {1} @@ -1931,7 +1666,7 @@ fn ad_hoc_order_projects_from_entrypoint_when_top_is_above_it() -> anyhow::Resul create_branches(&repo, tip, ["refs/heads/top", "refs/heads/bottom"])?; let meta = in_memory_meta(tmp.as_ref())?; - let graph = graph_with_branch_order( + let ws = workspace_with_branch_order( &repo, &*meta, "refs/heads/bottom", @@ -1940,17 +1675,15 @@ fn ad_hoc_order_projects_from_entrypoint_when_top_is_above_it() -> anyhow::Resul .validated()?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" - -โ””โ”€โ”€ โ–บ:1[0]:top - โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[1]:bottom - โ””โ”€โ”€ ๐Ÿยท960152d (โŒ‚|1) โ–บmain[๐ŸŒณ] - +* ๐Ÿ‘‰๐Ÿยท960152d (โŒ‚) โ–บbottom, โ–บmain[๐ŸŒณ], โ–บtop +layout: + empty chain anchors: 960152d^ "#]] ); snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" โŒ‚:bottom <> โœ“! โ””โ”€โ”€ โ‰ก:bottom {1} @@ -1973,7 +1706,7 @@ fn ad_hoc_three_branch_order_preserves_middle_empty_segment() -> anyhow::Result< )?; let meta = in_memory_meta(tmp.as_ref())?; - let graph = graph_with_branch_order( + let ws = workspace_with_branch_order( &repo, &*meta, "refs/heads/top", @@ -1982,18 +1715,15 @@ fn ad_hoc_three_branch_order_preserves_middle_empty_segment() -> anyhow::Result< .validated()?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:1[0]:top - โ””โ”€โ”€ โ–บ:2[1]:middle - โ””โ”€โ”€ โ–บ:0[2]:bottom - โ””โ”€โ”€ ๐Ÿยท960152d (โŒ‚|1) โ–บmain[๐ŸŒณ] - +* ๐Ÿ‘‰๐Ÿยท960152d (โŒ‚) โ–บbottom, โ–บmain[๐ŸŒณ], โ–บmiddle, โ–บtop +layout: + empty chain anchors: 960152d^ "#]] ); snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" โŒ‚:top <> โœ“! โ””โ”€โ”€ โ‰ก:top {1} @@ -2014,7 +1744,7 @@ fn ad_hoc_order_ignores_missing_metadata_refs_without_phantoms() -> anyhow::Resu create_branches(&repo, tip, ["refs/heads/top", "refs/heads/bottom"])?; let meta = in_memory_meta(tmp.as_ref())?; - let graph = graph_with_branch_order( + let ws = workspace_with_branch_order( &repo, &*meta, "refs/heads/top", @@ -2023,17 +1753,15 @@ fn ad_hoc_order_ignores_missing_metadata_refs_without_phantoms() -> anyhow::Resu .validated()?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:1[0]:top - โ””โ”€โ”€ โ–บ:0[1]:bottom - โ””โ”€โ”€ ๐Ÿยท960152d (โŒ‚|1) โ–บmain[๐ŸŒณ] - +* ๐Ÿ‘‰๐Ÿยท960152d (โŒ‚) โ–บbottom, โ–บmain[๐ŸŒณ], โ–บtop +layout: + empty chain anchors: 960152d^ "#]] ); snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" โŒ‚:top <> โœ“! โ””โ”€โ”€ โ‰ก:top {1} @@ -2055,7 +1783,7 @@ fn ad_hoc_order_does_not_force_diverged_refs_into_empty_stack() -> anyhow::Resul create_branches(&repo, top_tip, ["refs/heads/top"])?; let meta = in_memory_meta(tmp.as_ref())?; - let graph = graph_with_branch_order( + let ws = workspace_with_branch_order( &repo, &*meta, "refs/heads/top", @@ -2064,17 +1792,14 @@ fn ad_hoc_order_does_not_force_diverged_refs_into_empty_stack() -> anyhow::Resul .validated()?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:top - โ”œโ”€โ”€ ยท5cd63e5 (โŒ‚|1) - โ””โ”€โ”€ ๐Ÿยทfa91c94 (โŒ‚|1) โ–บbottom, โ–บmain[๐ŸŒณ] - +* ๐Ÿ‘‰ยท5cd63e5 (โŒ‚) โ–บtop +* ๐Ÿยทfa91c94 (โŒ‚) โ–บbottom, โ–บmain[๐ŸŒณ] "#]] ); snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" โŒ‚:top <> โœ“! โ””โ”€โ”€ โ‰ก:top {1} @@ -2102,7 +1827,7 @@ fn ad_hoc_order_preserves_empty_top_above_commit_owning_branch() -> anyhow::Resu create_branches(&repo, target_tip, ["refs/heads/main"])?; let meta = in_memory_meta(tmp.as_ref())?; - let graph = graph_with_branch_order( + let ws = workspace_with_branch_order( &repo, &*meta, "refs/heads/empty-top", @@ -2111,20 +1836,19 @@ fn ad_hoc_order_preserves_empty_top_above_commit_owning_branch() -> anyhow::Resu "refs/heads/commit-branch", "refs/heads/bottom", ], - )? - .validated()?; + )?; snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:3:empty-top <> โœ“! -โ””โ”€โ”€ โ‰ก:3:empty-top {1} - โ”œโ”€โ”€ :3:empty-top - โ”œโ”€โ”€ :0:commit-branch +โŒ‚:empty-top <> โœ“! +โ””โ”€โ”€ โ‰ก:empty-top {1} + โ”œโ”€โ”€ :empty-top + โ”œโ”€โ”€ :commit-branch โ”‚ โ””โ”€โ”€ ยท4782705 - โ”œโ”€โ”€ :1:bottom + โ”œโ”€โ”€ :bottom โ”‚ โ””โ”€โ”€ ยทdbc3a4c - โ””โ”€โ”€ :2:main[๐ŸŒณ] + โ””โ”€โ”€ :main[๐ŸŒณ] โ””โ”€โ”€ ยท67b14ca "#]] @@ -2151,7 +1875,7 @@ fn ad_hoc_order_keeps_lower_empty_branches_after_non_empty_move() -> anyhow::Res create_branches(&repo, target_tip, ["refs/heads/main"])?; let meta = in_memory_meta(tmp.as_ref())?; - let graph = graph_with_branch_order( + let ws = workspace_with_branch_order( &repo, &*meta, "refs/heads/commit-branch", @@ -2161,21 +1885,20 @@ fn ad_hoc_order_keeps_lower_empty_branches_after_non_empty_move() -> anyhow::Res "refs/heads/empty-low", "refs/heads/base", ], - )? - .validated()?; + )?; snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:0:commit-branch <> โœ“! -โ””โ”€โ”€ โ‰ก:0:commit-branch {1} - โ”œโ”€โ”€ :0:commit-branch +โŒ‚:commit-branch <> โœ“! +โ””โ”€โ”€ โ‰ก:commit-branch {1} + โ”œโ”€โ”€ :commit-branch โ”‚ โ””โ”€โ”€ ยท5380c0a - โ”œโ”€โ”€ :3:empty-top - โ”œโ”€โ”€ :4:empty-low - โ”œโ”€โ”€ :2:base + โ”œโ”€โ”€ :empty-top + โ”œโ”€โ”€ :empty-low + โ”œโ”€โ”€ :base โ”‚ โ””โ”€โ”€ ยทa5cd64d - โ””โ”€โ”€ :1:main[๐ŸŒณ] + โ””โ”€โ”€ :main[๐ŸŒณ] โ””โ”€โ”€ ยท67b14ca "#]] @@ -2199,7 +1922,7 @@ fn ad_hoc_order_scopes_empty_segments_to_active_chain() -> anyhow::Result<()> { )?; let meta = in_memory_meta(tmp.as_ref())?; - let graph = graph_with_branch_orders( + let ws = workspace_with_branch_orders( &repo, &*meta, "refs/heads/top", @@ -2211,17 +1934,15 @@ fn ad_hoc_order_scopes_empty_segments_to_active_chain() -> anyhow::Result<()> { .validated()?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:1[0]:top - โ””โ”€โ”€ โ–บ:0[1]:bottom - โ””โ”€โ”€ ๐Ÿยท960152d (โŒ‚|1) โ–บmain[๐ŸŒณ], โ–บother-bottom, โ–บother-top - +* ๐Ÿ‘‰๐Ÿยท960152d (โŒ‚) โ–บbottom, โ–บmain[๐ŸŒณ], โ–บother-bottom, โ–บother-top, โ–บtop +layout: + empty chain anchors: 960152d^ "#]] ); snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" โŒ‚:top <> โœ“! โ””โ”€โ”€ โ‰ก:top {1} @@ -2234,6 +1955,80 @@ fn ad_hoc_order_scopes_empty_segments_to_active_chain() -> anyhow::Result<()> { Ok(()) } +/// The single-branch-mode e2e shape: `refs/heads/gitbutler/workspace` still exists (left at +/// the target base by `but setup`), so the build takes the managed path โ€” yet HEAD is on a +/// plain branch `top` that points at the same commit as `bottom`, with a persisted +/// `top` โ†’ `bottom` ordering. The ordering must apply on the managed path too, rendering +/// `top` as an empty segment above `bottom` instead of one combined branch. +#[test] +fn ad_hoc_same_tip_pair_above_commit_run_with_target() -> anyhow::Result<()> { + let (tmp, repo) = empty_repo()?; + let base = commit(&repo, "base")?; + let c1 = commit_with_parent(&repo, "first", base)?; + let c2 = commit_with_parent(&repo, "second", c1)?; + let seed = commit_with_parent(&repo, "third", c2)?; + repo.commit( + "refs/heads/gitbutler/workspace", + "GitButler Workspace Commit", + repo.object_hash().empty_tree(), + Some(base), + )?; + create_branches( + &repo, + base, + [ + "refs/heads/master", + "refs/remotes/origin/master", + "refs/heads/gitbutler/target", + ], + )?; + create_branches(&repo, seed, ["refs/heads/top", "refs/heads/bottom"])?; + let meta = in_memory_meta(tmp.as_ref())?; + + let entrypoint_ref = ref_name("refs/heads/top"); + let overlay = Overlay::default() + .with_branch_stack_order_override(["refs/heads/top", "refs/heads/bottom"].map(ref_name)); + let ws = Workspace::from_tip( + seed.attach(&repo), + Some(entrypoint_ref), + &*meta, + but_core::ref_metadata::ProjectMeta { + target_ref: Some(ref_name("refs/remotes/origin/master")), + target_commit_id: Some(base), + push_remote: Some("origin".into()), + }, + standard_options(), + )? + .redo(&repo, &*meta, overlay)? + .validated()?; + + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยท96fee4c (โŒ‚) โ–บbottom, โ–บmain[๐ŸŒณ], โ–บtop +* ยทa6448ba (โŒ‚) +* ยทff07efd (โŒ‚) +* ๐Ÿยท86719d5 (โŒ‚) โ–บgitbutler/target, โ–บmaster, โ–บorigin/master <> origin/master +layout: + empty chain anchors: 96fee4c^ +"#]] + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +โŒ‚:top <> โœ“refs/remotes/origin/master on 86719d5 +โ””โ”€โ”€ โ‰ก:top on 86719d5 {1} + โ”œโ”€โ”€ :top + โ””โ”€โ”€ :bottom + โ”œโ”€โ”€ ยท96fee4c โ–บmain[๐ŸŒณ] + โ”œโ”€โ”€ ยทa6448ba + โ””โ”€โ”€ ยทff07efd + +"#]] + ); + Ok(()) +} + mod overlay; mod with_workspace; @@ -2244,7 +2039,7 @@ pub use utils::{ read_only_in_memory_scenario, standard_options, }; -use crate::init::utils::{in_memory_meta, standard_options_with_extra_target}; +use crate::walk::utils::{in_memory_meta, standard_options_with_extra_target}; fn ref_name(name: &str) -> gix::refs::FullName { name.try_into().expect("valid full ref name") @@ -2309,21 +2104,21 @@ fn create_branches( Ok(()) } -fn graph_with_branch_order( +fn workspace_with_branch_order( repo: &gix::Repository, meta: &impl but_core::RefMetadata, entrypoint_ref: &str, order: [&str; N], -) -> anyhow::Result { - graph_with_branch_orders(repo, meta, entrypoint_ref, &[&order]) +) -> anyhow::Result { + workspace_with_branch_orders(repo, meta, entrypoint_ref, &[&order]) } -fn graph_with_branch_orders( +fn workspace_with_branch_orders( repo: &gix::Repository, meta: &impl but_core::RefMetadata, entrypoint_ref: &str, orders: &[&[&str]], -) -> anyhow::Result { +) -> anyhow::Result { let entrypoint_ref = ref_name(entrypoint_ref); let tip = repo .find_reference(entrypoint_ref.as_ref())? @@ -2333,12 +2128,12 @@ fn graph_with_branch_orders( for order in orders { overlay = overlay.with_branch_stack_order_override(order.iter().copied().map(ref_name)); } - Graph::from_commit_traversal( + Workspace::from_tip( tip.attach(repo), Some(entrypoint_ref), meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? - .redo_traversal_with_overlay(repo, meta, overlay) + .redo(repo, meta, overlay) } diff --git a/crates/but-graph/tests/graph/init/utils.rs b/crates/but-graph/tests/graph/walk/utils.rs similarity index 95% rename from crates/but-graph/tests/graph/init/utils.rs rename to crates/but-graph/tests/graph/walk/utils.rs index 53659cecc1d..092b8fbeb3c 100644 --- a/crates/but-graph/tests/graph/init/utils.rs +++ b/crates/but-graph/tests/graph/walk/utils.rs @@ -149,22 +149,21 @@ pub fn add_stack_with_segments( stack_id } -pub fn standard_options() -> but_graph::init::Options { - but_graph::init::Options { +pub fn standard_options() -> but_graph::walk::Options { + but_graph::walk::Options { collect_tags: true, commits_limit_hint: None, commits_limit_recharge_location: vec![], hard_limit: None, extra_target_commit_id: None, - dangerously_skip_postprocessing_for_debugging: false, } } pub fn standard_options_with_extra_target( repo: &gix::Repository, name: &str, -) -> but_graph::init::Options { - but_graph::init::Options { +) -> but_graph::walk::Options { + but_graph::walk::Options { extra_target_commit_id: Some(repo.rev_parse_single(name).expect("present").detach()), ..standard_options() } diff --git a/crates/but-graph/tests/graph/workspace/merge_base_with_target_branch.rs b/crates/but-graph/tests/graph/workspace/merge_base_with_target_branch.rs index 6c1322a31c2..49b6782ebcd 100644 --- a/crates/but-graph/tests/graph/workspace/merge_base_with_target_branch.rs +++ b/crates/but-graph/tests/graph/workspace/merge_base_with_target_branch.rs @@ -1,9 +1,9 @@ -use but_graph::Graph; +use but_graph::Workspace; use but_testsupport::visualize_commit_graph_all; -use snapbox::IntoData; +use snapbox::prelude::*; use super::project_meta; -use crate::init::utils::{ +use crate::walk::utils::{ add_workspace, add_workspace_without_target, read_only_in_memory_scenario, standard_options, standard_options_with_extra_target, }; @@ -32,9 +32,8 @@ fn with_target_ref() -> anyhow::Result<()> { add_workspace(&mut meta); - let ws = Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? - .validated()? - .into_workspace()?; + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; // We have a target_ref but nothing else assert!(ws.target_ref.is_some()); @@ -81,14 +80,13 @@ fn with_extra_target_when_no_target_ref() -> anyhow::Result<()> { meta.data_mut().default_target = None; // Use extra_target to set a lower bound - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, project_meta(&*meta), standard_options_with_extra_target(&repo, "main"), )? .validated()?; - let ws = graph.into_workspace()?; assert!(ws.target_ref.is_none()); let expected_target_id = repo.rev_parse_single("main")?.detach(); @@ -112,9 +110,8 @@ fn returns_none_when_no_target_is_set() -> anyhow::Result<()> { let (repo, mut meta) = read_only_in_memory_scenario("ws/no-target-without-ws-commit")?; add_workspace_without_target(&mut meta); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - let ws = graph.into_workspace()?; + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; assert!(ws.target_ref.is_none(), "should not have target_ref"); assert!(ws.target_commit.is_none(), "should not have target_commit"); @@ -134,9 +131,8 @@ fn returns_none_when_commit_not_in_graph() -> anyhow::Result<()> { let (repo, mut meta) = read_only_in_memory_scenario("ws/local-target-and-stack")?; add_workspace(&mut meta); - let ws = Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? - .validated()? - .into_workspace()?; + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; let res = ws.merge_base_with_target_branch(repo.object_hash().null()); assert!( diff --git a/crates/but-graph/tests/graph/workspace/mod.rs b/crates/but-graph/tests/graph/workspace/mod.rs index 5483cc2f5ed..f8e2739f7b2 100644 --- a/crates/but-graph/tests/graph/workspace/mod.rs +++ b/crates/but-graph/tests/graph/workspace/mod.rs @@ -1,5 +1,4 @@ #[cfg(feature = "legacy")] -mod legacy; mod merge_base_with_target_branch; mod remote_name; mod resolved_target_commit_id; diff --git a/crates/but-graph/tests/graph/workspace/remote_name.rs b/crates/but-graph/tests/graph/workspace/remote_name.rs index 8ebae345536..2543e3ab8c4 100644 --- a/crates/but-graph/tests/graph/workspace/remote_name.rs +++ b/crates/but-graph/tests/graph/workspace/remote_name.rs @@ -1,10 +1,12 @@ use but_core::RefMetadata; -use but_graph::Graph; -use but_testsupport::{graph_tree, visualize_commit_graph_all}; +use but_graph::Workspace; +use but_meta::virtual_branches_legacy_types::Target; +use but_testsupport::{graph_dag, visualize_commit_graph_all}; use super::project_meta; -use crate::init::utils::{ - add_workspace, add_workspace_without_target, read_only_in_memory_scenario, standard_options, +use crate::walk::utils::{ + add_workspace, add_workspace_without_target, named_read_only_in_memory_scenario, + read_only_in_memory_scenario, standard_options, }; #[test] @@ -13,9 +15,8 @@ fn with_target_ref_extracts_remote_name() -> anyhow::Result<()> { add_workspace(&mut meta); - let ws = Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? - .validated()? - .into_workspace()?; + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; assert!(ws.target_ref.is_some()); assert_eq!( @@ -27,15 +28,45 @@ fn with_target_ref_extracts_remote_name() -> anyhow::Result<()> { Ok(()) } +#[test] +fn slash_named_remote_extracts_the_full_remote_name() -> anyhow::Result<()> { + let (repo, mut meta) = named_read_only_in_memory_scenario("slash-remote", "slash-remote")?; + + add_workspace(&mut meta); + // The target remote's name contains a slash โ€” extraction must longest-match + // against the configured remote names, never split at the first slash. + meta.data_mut().default_target = Some(Target { + branch: gitbutler_reference::RemoteRefname::new("special/origin", "main"), + remote_url: "does not matter".to_string(), + sha: gix::hash::Kind::Sha1.null(), + push_remote_name: None, + }); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + + assert_eq!( + ws.target_ref_name().map(|rn| rn.as_bstr()), + Some("refs/remotes/special/origin/main".into()), + "the target resolves through the slash-named remote" + ); + assert_eq!( + ws.remote_name(), + Some("special/origin".into()), + "the full remote name is extracted, not the first path component" + ); + + Ok(()) +} + #[test] fn returns_none_when_no_target_and_no_push_remote() -> anyhow::Result<()> { let (repo, mut meta) = read_only_in_memory_scenario("ws/no-target-without-ws-commit")?; add_workspace_without_target(&mut meta); - let ws = Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? - .validated()? - .into_workspace()?; + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; assert!(ws.target_ref.is_none(), "should not have a target_ref"); assert!( @@ -68,22 +99,12 @@ fn target_local_tracking_ref_exists_when_other_branch_metadata_names_the_same_ti branch.update_times(false); meta.set_branch(&branch)?; - let ws = Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? - .validated()? - .into_workspace()?; + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; // the target remote and its local tracking branch get sibling links even when another branch owns the shared commit snapbox::assert_data_eq!( - graph_tree(&ws.graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:2[2]:A -โ”‚ โ””โ”€โ”€ โœ‚ยทbce0c5e (โŒ‚|๐Ÿ˜|โœ“|1) โ–บB -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:3: - โ””โ”€โ”€ โ–บ:3[1]:main <> origin/main โ†’:1: - โ””โ”€โ”€ โ†’:2: (A) - -"#]] + graph_dag(&ws), + snapbox::str!["* ๐Ÿ‘‰โœ‚ยทbce0c5e (โŒ‚|๐Ÿ˜|โœ“) โ–บA, โ–บB, โ–บmain, โ–บorigin/main <> origin/main"] ); assert_eq!( @@ -91,12 +112,5 @@ fn target_local_tracking_ref_exists_when_other_branch_metadata_names_the_same_ti Some("refs/remotes/origin/main".into()), "fixture should resolve the workspace target as origin/main" ); - assert_eq!( - ws.target_local_tracking_ref_info() - .map(|ri| ri.ref_name.to_string()), - Some("refs/heads/main".to_string()), - "target/local tracking relationship should be available from the graph projection" - ); - Ok(()) } diff --git a/crates/but-graph/tests/graph/workspace/resolved_target_commit_id.rs b/crates/but-graph/tests/graph/workspace/resolved_target_commit_id.rs index 13d2f99a241..7e04c3b0d5a 100644 --- a/crates/but-graph/tests/graph/workspace/resolved_target_commit_id.rs +++ b/crates/but-graph/tests/graph/workspace/resolved_target_commit_id.rs @@ -1,9 +1,9 @@ -use but_graph::Graph; +use but_graph::Workspace; use but_testsupport::visualize_commit_graph_all; -use snapbox::IntoData; +use snapbox::prelude::*; use super::project_meta; -use crate::init::utils::{ +use crate::walk::utils::{ add_workspace, add_workspace_with_target, add_workspace_without_target, read_only_in_memory_scenario, standard_options, standard_options_with_extra_target, }; @@ -34,9 +34,8 @@ fn returns_target_tip_when_stacks_have_different_bases() -> anyhow::Result<()> { // resolved_target_commit_id should return M4 (the tip of origin/main). add_workspace(&mut meta); - let ws = Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? - .validated()? - .into_workspace()?; + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; let tip = ws.resolved_target_commit_id(); let expected_m4 = repo.rev_parse_single(":/M4")?.detach(); @@ -72,9 +71,8 @@ fn returns_target_tip_when_one_stack_is_above_target() -> anyhow::Result<()> { // resolved_target_commit_id should return M3 (the tip of origin/main). add_workspace(&mut meta); - let ws = Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? - .validated()? - .into_workspace()?; + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; let tip = ws.resolved_target_commit_id(); let expected_m3 = repo.rev_parse_single(":/M3")?.detach(); @@ -113,9 +111,8 @@ fn prefers_target_commit_over_target_ref() -> anyhow::Result<()> { let m2 = repo.rev_parse_single(":/M2")?.detach(); add_workspace_with_target(&mut meta, m2); - let ws = Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? - .validated()? - .into_workspace()?; + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; assert!(ws.target_ref.is_some(), "target_ref should be set"); assert!(ws.target_commit.is_some(), "target_commit should be set"); @@ -135,9 +132,8 @@ fn returns_none_when_no_target() -> anyhow::Result<()> { let (repo, mut meta) = read_only_in_memory_scenario("ws/no-target-without-ws-commit")?; add_workspace_without_target(&mut meta); - let ws = Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? - .validated()? - .into_workspace()?; + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; assert!( ws.resolved_target_commit_id().is_none(), @@ -154,14 +150,13 @@ fn returns_extra_target_without_target_ref() -> anyhow::Result<()> { add_workspace(&mut meta); meta.data_mut().default_target = None; - let ws = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, project_meta(&*meta), standard_options_with_extra_target(&repo, "main"), )? - .validated()? - .into_workspace()?; + .validated()?; let expected_target_id = repo.rev_parse_single("main")?.detach(); assert_eq!( diff --git a/crates/but-hunk-assignment/src/lib.rs b/crates/but-hunk-assignment/src/lib.rs index c7b1657c086..c4f24acb54c 100644 --- a/crates/but-hunk-assignment/src/lib.rs +++ b/crates/but-hunk-assignment/src/lib.rs @@ -897,8 +897,8 @@ mod tests { use bstr::BString; use but_core::{HunkHeader, ref_metadata::StackId}; use but_graph::{ - SegmentIndex, Workspace, - workspace::{Stack, StackSegment, WorkspaceKind}, + Workspace, + workspace::{Stack, StackSegment}, }; use super::*; @@ -965,61 +965,37 @@ mod tests { } fn empty_workspace() -> Workspace { - Workspace { - graph: but_graph::Graph::default(), - id: Default::default(), - kind: WorkspaceKind::AdHoc, - stacks: vec![], - lower_bound: None, - lower_bound_segment_id: None, - target_ref: None, - target_commit: None, - metadata: None, - } + Workspace::empty_ad_hoc_for_testing() } fn branch_ref(name: &str) -> gix::refs::FullName { gix::refs::FullName::try_from(name.to_owned()).expect("test branch ref should be valid") } - fn stack_segment(id: usize, branch_ref_name: Option<&str>) -> StackSegment { - StackSegment { - ref_info: branch_ref_name.map(|name| but_graph::RefInfo { - ref_name: branch_ref(name), - commit_id: None, - worktree: None, - }), - remote_tracking_ref_name: None, - sibling_segment_id: None, - remote_tracking_branch_segment_id: None, - id: SegmentIndex::new(id), - commits: vec![], - commits_outside: None, - base: None, - base_segment_id: None, - commits_by_segment: vec![], - commits_on_remote: vec![], - metadata: None, - is_entrypoint: false, - } + fn stack_segment(branch_ref_name: Option<&str>) -> StackSegment { + let mut segment = StackSegment::default_for_testing(); + segment.ref_info = branch_ref_name.map(|name| but_graph::RefInfo { + ref_name: branch_ref(name), + commit_id: None, + worktree: None, + }); + segment } - fn stack(id: Option, branch_ref_names: &[&str], segment_offset: usize) -> Stack { + fn stack(id: Option, branch_ref_names: &[&str]) -> Stack { Stack { id: id.map(stack_id_seq), segments: branch_ref_names .iter() - .enumerate() - .map(|(idx, name)| stack_segment(segment_offset + idx, Some(name))) + .map(|name| stack_segment(Some(name))) .collect(), } } fn workspace_with_stacks(stacks: Vec) -> Workspace { - Workspace { - stacks, - ..empty_workspace() - } + let mut ws = empty_workspace(); + ws.stacks = stacks; + ws } fn deep_eq(a: &HunkAssignment, b: &HunkAssignment) -> bool { @@ -1224,8 +1200,8 @@ mod tests { #[test] fn test_derive_stack_ids_replaces_stale_stack_id_from_branch_ref() { let workspace = workspace_with_stacks(vec![ - stack(Some(1), &["refs/heads/feature-a"], 0), - stack(Some(2), &["refs/heads/feature-b"], 10), + stack(Some(1), &["refs/heads/feature-a"]), + stack(Some(2), &["refs/heads/feature-b"]), ]); let mut assignments = vec![ HunkAssignment::new("foo.rs", 10, 5, Some(1), Some(1)) @@ -1239,7 +1215,7 @@ mod tests { #[test] fn test_derive_stack_ids_clears_stack_id_for_missing_branch_ref() { - let workspace = workspace_with_stacks(vec![stack(Some(1), &["refs/heads/feature-a"], 0)]); + let workspace = workspace_with_stacks(vec![stack(Some(1), &["refs/heads/feature-a"])]); let mut assignments = vec![ HunkAssignment::new("foo.rs", 10, 5, Some(1), Some(1)) .with_branch_ref_bytes(Some("refs/heads/missing")), @@ -1252,7 +1228,7 @@ mod tests { #[test] fn test_derive_stack_ids_returns_none_when_matching_stack_has_no_id() { - let workspace = workspace_with_stacks(vec![stack(None, &["refs/heads/feature-a"], 0)]); + let workspace = workspace_with_stacks(vec![stack(None, &["refs/heads/feature-a"])]); let mut assignments = vec![ HunkAssignment::new("foo.rs", 10, 5, Some(1), Some(1)) .with_branch_ref_bytes(Some("refs/heads/feature-a")), @@ -1268,7 +1244,6 @@ mod tests { let workspace = workspace_with_stacks(vec![stack( Some(2), &["refs/heads/feature-tip", "refs/heads/feature-base"], - 0, )]); let mut assignments = vec![HunkAssignment::new("foo.rs", 10, 5, Some(2), Some(1))]; @@ -1282,7 +1257,7 @@ mod tests { #[test] fn test_backfill_branch_ref_from_legacy_stack_id_ignores_unknown_stack() { - let workspace = workspace_with_stacks(vec![stack(Some(1), &["refs/heads/feature-a"], 0)]); + let workspace = workspace_with_stacks(vec![stack(Some(1), &["refs/heads/feature-a"])]); let mut assignments = vec![HunkAssignment::new("foo.rs", 10, 5, Some(2), Some(1))]; backfill_branch_ref_from_legacy_stack_id(&mut assignments, &workspace); diff --git a/crates/but-meta/src/legacy/mod.rs b/crates/but-meta/src/legacy/mod.rs index c143da09787..00d00363369 100644 --- a/crates/but-meta/src/legacy/mod.rs +++ b/crates/but-meta/src/legacy/mod.rs @@ -2,7 +2,7 @@ use std::{ any::Any, cell::RefCell, cmp::Ordering, - collections::{BTreeMap, BTreeSet, HashSet, btree_map}, + collections::{BTreeMap, BTreeSet, HashSet}, ops::{Deref, DerefMut}, path::{Path, PathBuf}, time::Instant, @@ -163,7 +163,7 @@ impl Snapshot { let mut changed = false; let mut empty_stacks_to_remove = Vec::new(); let null_id = gix::hash::Kind::Sha1.null(); - let projected_segment_ids = projected_workspace + let projected_refnames = projected_workspace .filter(|workspace| workspace.kind.has_managed_commit()) .map(|workspace| { workspace @@ -173,11 +173,11 @@ impl Snapshot { .filter_map(|segment| { segment .ref_name() - .map(|ref_name| (ref_name.shorten().to_string(), segment.id)) + .map(|ref_name| ref_name.shorten().to_string()) }) - .collect::>() + .collect::>() }); - let mut seen_refnames = BTreeMap::>::new(); + let mut seen_refnames = BTreeSet::::new(); for (stack_id, stack) in self .content .branches @@ -212,23 +212,15 @@ impl Snapshot { if !seen_in_this_stack.insert(head.name.clone()) { return Some(head_idx); } - let projected_segment_id = projected_segment_ids - .as_ref() - .and_then(|ids| ids.get(&head.name).copied()); - match seen_refnames.entry(head.name.clone()) { - btree_map::Entry::Vacant(entry) => { - entry.insert(projected_segment_id); - None - } - btree_map::Entry::Occupied(entry) => { - let preserve_duplicate = entry - .get() - .as_ref() - .zip(projected_segment_id) - .is_some_and(|(seen, current)| *seen == current); - (!preserve_duplicate).then_some(head_idx) - } + if seen_refnames.insert(head.name.clone()) { + return None; } + // A repeated head name survives only when the projection still shows + // a segment of that name โ€” the same one every duplicate resolves to. + let preserve_duplicate = projected_refnames + .as_ref() + .is_some_and(|names| names.contains(&head.name)); + (!preserve_duplicate).then_some(head_idx) }) .collect(); for head_idx in head_indices_to_remove.into_iter().rev() { @@ -274,16 +266,15 @@ impl Snapshot { }, write_on_drop: false, }); - let graph = but_graph::Graph::from_commit_traversal( + but_graph::Workspace::from_tip( commit_id, reference.name().to_owned(), &*sideeffect_free_meta, sideeffect_free_meta .workspace(reference.name())? .project_meta(), - but_graph::init::Options::limited(), - )?; - graph.into_workspace() + but_graph::walk::Options::limited(), + ) } #[instrument(level = "debug", skip(self, repo, projected_workspace))] @@ -292,7 +283,10 @@ impl Snapshot { repo: &gix::Repository, projected_workspace: Option<&but_graph::Workspace>, ) -> anyhow::Result<()> { - fn make_heads_match(ws_stack: &but_graph::workspace::Stack, vb_stack: &mut Stack) -> bool { + fn make_heads_match( + ws_stack: &but_graph::workspace::Stack, + vb_stack: &mut Stack, + ) -> (bool, Vec) { // Always leave extra segments. // Add missing segments @@ -308,15 +302,18 @@ impl Snapshot { }) .collect(); + let mut added_head_names = Vec::new(); for (segment, segment_name) in segments_to_add { let first_commit_or_null = segment .commits .first() .map_or(gix::hash::Kind::Sha1.null(), |c| c.id); tracing::warn!(segment_name=%segment_name.shorten(), first_commit_or_null=%first_commit_or_null, stack_id=?vb_stack.id, "Adding head to stack"); + let name = segment_name.shorten().to_string(); + added_head_names.push(name.clone()); vb_stack.heads.push(StackBranch { head: first_commit_or_null, - name: segment_name.shorten().to_string(), + name, pr_number: None, archived: false, review_id: None, @@ -342,7 +339,7 @@ impl Snapshot { }); // The ws_stack order is top to bottom, the other is bottom to top. vb_stack.heads.reverse(); - vb_stack.heads != previous_heads + (vb_stack.heads != previous_heads, added_head_names) } let owned_workspace; @@ -393,7 +390,7 @@ impl Snapshot { stack.id = in_workspace_stack_id; stack }); - let made_heads_match = make_heads_match(ws_stack, vb_stack); + let (made_heads_match, added_head_names) = make_heads_match(ws_stack, vb_stack); if !vb_stack.in_workspace { tracing::warn!( "Fixing stale metadata of stack {in_workspace_stack_id} to be considered inside the workspace", @@ -410,6 +407,30 @@ impl Snapshot { if inserted_new_stack { self.set_changed_to_necessitate_write(); } + // Adding a head is a move - drop it from every other stack, or the same branch is + // duplicated across stacks and can survive its own removal later. + // Stacks emptied by this are removed in `enforce_constraints()`. + let mut moved_head = false; + for (other_stack_id, other_stack) in self + .content + .branches + .iter_mut() + .filter(|(id, _)| **id != in_workspace_stack_id) + { + for added_name in &added_head_names { + let head_count = other_stack.heads.len(); + other_stack.heads.retain(|sb| sb.name != *added_name); + if other_stack.heads.len() != head_count { + tracing::warn!( + "Moved head '{added_name}' from stack {other_stack_id} to stack {in_workspace_stack_id}" + ); + moved_head = true; + } + } + } + if moved_head { + self.set_changed_to_necessitate_write(); + } } let stack_ids_to_mark_outside_workspace: Vec<_> = original_stack_ids diff --git a/crates/but-meta/tests/meta/ref_metadata_legacy.rs b/crates/but-meta/tests/meta/ref_metadata_legacy.rs index 5d0d4c80d2f..c22568e5832 100644 --- a/crates/but-meta/tests/meta/ref_metadata_legacy.rs +++ b/crates/but-meta/tests/meta/ref_metadata_legacy.rs @@ -1487,12 +1487,12 @@ Some( // The above being stable already fixes `dlib`. let repo = but_testsupport::read_only_in_memory_scenario("dlib-standin")?; - let graph = but_graph::Graph::from_commit_traversal( + let ws = but_graph::Workspace::from_tip( repo.find_reference(ws_ref_name)?.peel_to_id()?, Some(ws_ref_name.to_owned()), &store, store.workspace(ws_ref_name)?.project_meta(), - but_graph::init::Options::limited(), + but_graph::walk::Options::limited(), )?; // It looks very empty without reconciliation, as if it had not found any metadata (even though it's there). // The problem is that StackId {1} refers to stack that is also marked as outside the workspace, so it's not really @@ -1503,9 +1503,9 @@ Some( // test was tuned for a certain outcome and now this becomes more obvious. But whatever, it's legacy and // it doesn't fail anymore. snapbox::assert_data_eq!( - but_testsupport::graph_workspace_determinisitcally(&graph.into_workspace()?).to_string(), + but_testsupport::graph_workspace_determinisitcally(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace <> โœ“refs/remotes/origin/main on bce0c5e +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on bce0c5e "#]] ); @@ -1537,17 +1537,17 @@ Some( ); let ws = store.workspace(ws_ref_name)?; - let graph = but_graph::Graph::from_commit_traversal( + let graph_ws = but_graph::Workspace::from_tip( repo.find_reference(ws_ref_name)?.peel_to_id()?, Some(ws_ref_name.to_owned()), &store, store.workspace(ws_ref_name)?.project_meta(), - but_graph::init::Options::limited(), + but_graph::walk::Options::limited(), )?; snapbox::assert_data_eq!( - but_testsupport::graph_workspace_determinisitcally(&graph.into_workspace()?).to_string(), + but_testsupport::graph_workspace_determinisitcally(&graph_ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:0:gitbutler/workspace <> โœ“refs/remotes/origin/main on bce0c5e +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on bce0c5e "#]] ); diff --git a/crates/but-rebase/Cargo.toml b/crates/but-rebase/Cargo.toml index bf56bde23f5..576d3e9850c 100644 --- a/crates/but-rebase/Cargo.toml +++ b/crates/but-rebase/Cargo.toml @@ -32,7 +32,6 @@ bstr.workspace = true tempfile.workspace = true serde.workspace = true toml.workspace = true -petgraph.workspace = true sapling-renderdag.workspace = true schemars = { workspace = true, optional = true } but-schemars = { workspace = true, optional = true } diff --git a/crates/but-rebase/docs/commit_parentage.md b/crates/but-rebase/docs/commit_parentage.md index 7c2a3242b1d..35c33c96fc1 100644 --- a/crates/but-rebase/docs/commit_parentage.md +++ b/crates/but-rebase/docs/commit_parentage.md @@ -24,7 +24,7 @@ Method: `editor.order_commit_selectors_by_parentage(selectors) -> Result rank` by traversing the entire editor step graph from all child-most nodes. +Build a map `commit_id -> rank` by traversing the entire editor commit graph from all child-most nodes. Implementation notes: diff --git a/crates/but-rebase/docs/merge_commit_changes.md b/crates/but-rebase/docs/merge_commit_changes.md index 28bd9ce0933..feb3f4b185f 100644 --- a/crates/but-rebase/docs/merge_commit_changes.md +++ b/crates/but-rebase/docs/merge_commit_changes.md @@ -35,13 +35,13 @@ it derives: The planner then emits only non-pruned selected-chain tips. -Even though traversal and pruning are driven by the editor step graph, the +Even though traversal and pruning are driven by the editor commit graph, the planner still takes first-parent and tree semantics from the commit objects in the editor's in-memory repository. In other words: - the in-memory repository is the source of truth for commit parents, trees, and SHAs, -- the step graph is used only to walk the selected region deterministically and +- the commit graph is used only to walk the selected region deterministically and to discover the target ancestry cone quickly, and - callers are expected to keep those two views aligned before using these helpers. @@ -59,7 +59,7 @@ the editor's in-memory repository. In other words: - A pruned selected first parent may still define the `base_tree_id` boundary for a surviving descendant, so a surviving tip can emit `B..C` even when `B` is pruned. -- The editor step graph is expected to represent the same topology as the +- The editor commit graph is expected to represent the same topology as the editor's in-memory repository. These helpers do not treat step-graph rewiring as a new source of commit parent truth. - The editor is assumed to already be normalized and up to date before calling diff --git a/crates/but-rebase/src/graph_rebase/cherry_pick.rs b/crates/but-rebase/src/graph_rebase/cherry_pick.rs index 631526171b4..3bb6901c70b 100644 --- a/crates/but-rebase/src/graph_rebase/cherry_pick.rs +++ b/crates/but-rebase/src/graph_rebase/cherry_pick.rs @@ -23,11 +23,11 @@ pub enum CherryPickOutcome { /// Represents the cases where either the source or the target commits failed to /// merge cleanly. FailedToMergeBases { - /// Whther the merge operation performed on the list of bases failed. + /// Whether the merge operation performed on the list of bases failed. base_merge_failed: bool, /// The shas of the commits that we were trying to cherry pick from. bases: Option>, - /// Whther the merge operation performed on the list of ontos failed. + /// Whether the merge operation performed on the list of ontos failed. onto_merge_failed: bool, /// The shas of the commits that we were trying to cherry pick onto. ontos: Option>, @@ -116,13 +116,14 @@ pub fn cherry_pick( return Ok(CherryPickOutcome::Identity(target.id.detach())); } - let base_t = find_base_tree(&target, tree_merge_mode)?; + let base_tree = find_base_tree(&target, tree_merge_mode)?; // We always want the "theirs-ist" side of the target if it's conflicted. - let target_t = find_real_tree(&target, TreeKind::Theirs)?; + let target_tree = find_real_tree(&target, TreeKind::Theirs)?; // We want to cherry-pick onto the merge result. - let onto_t = merged_tree_from_commits(repo, ontos, tree_merge_mode, TreeKind::AutoResolution)?; + let onto_tree = + merged_tree_from_commits(repo, ontos, tree_merge_mode, TreeKind::AutoResolution)?; - match (&base_t, &onto_t) { + match (&base_tree, &onto_tree) { (MergeOutcome::NoCommit, MergeOutcome::NoCommit) if pick_mode == PickMode::Force => { // We should only end up here when trying to force-pick a parentless commit. At that // point, it's safe to simply recreate that commit outright. @@ -162,8 +163,8 @@ pub fn cherry_pick( repo, &target, ontos, - &base_t, - target_t.detach(), + &base_tree, + target_tree.detach(), tree_merge_mode, sign_commit, )? { @@ -182,43 +183,63 @@ pub fn cherry_pick( MergeOutcome::Success(_) | MergeOutcome::NoCommit, ) => { let empty_tree = gix::ObjectId::empty_tree(gix::hash::Kind::Sha1); - let base_t = base_t.object_id().unwrap_or(empty_tree); - let onto_t = onto_t.object_id().unwrap_or(empty_tree); - - let mut outcome = repo.merge_trees( - base_t, - onto_t, - target_t, - repo.default_merge_labels(), - repo.merge_options_force_ours()?, - )?; - let tree_id = outcome.tree.write()?; - - let conflict_kind = gix::merge::tree::TreatAsUnresolved::forced_resolution(); - if outcome.has_unresolved_conflicts(conflict_kind) { - let conflicted_commit = commit_from_conflicted_tree( - ontos, - target, - tree_id, - outcome, - conflict_kind, - base_t, - onto_t, - target_t.detach(), - sign_commit, - )?; - Ok(CherryPickOutcome::ConflictedCommit( - conflicted_commit.detach(), - )) - } else { - Ok(CherryPickOutcome::Commit( - commit_from_unconflicted_tree(ontos, target, tree_id, sign_commit)?.detach(), - )) - } + let base_tree = base_tree.object_id().unwrap_or(empty_tree); + let onto_tree = onto_tree.object_id().unwrap_or(empty_tree); + merge_into_outcome( + repo, + target, + ontos, + base_tree, + onto_tree, + target_tree.detach(), + sign_commit, + ) } } } +/// Merge `ours`/`theirs` over `base`, write the tree, and wrap the result as `target` +/// replayed onto `ontos` โ€” conflicted or clean by `forced_resolution`. +fn merge_into_outcome( + repo: &gix::Repository, + target: but_core::Commit<'_>, + ontos: &[gix::ObjectId], + base: gix::ObjectId, + ours: gix::ObjectId, + theirs: gix::ObjectId, + sign_commit: SignCommit, +) -> Result { + let mut outcome = repo.merge_trees( + base, + ours, + theirs, + repo.default_merge_labels(), + repo.merge_options_force_ours()?, + )?; + let tree_id = outcome.tree.write()?; + let conflict_kind = gix::merge::tree::TreatAsUnresolved::forced_resolution(); + if outcome.has_unresolved_conflicts(conflict_kind) { + let conflicted_commit = commit_from_conflicted_tree( + ontos, + target, + tree_id, + outcome, + conflict_kind, + base, + ours, + theirs, + sign_commit, + )?; + Ok(CherryPickOutcome::ConflictedCommit( + conflicted_commit.detach(), + )) + } else { + Ok(CherryPickOutcome::Commit( + commit_from_unconflicted_tree(ontos, target, tree_id, sign_commit)?.detach(), + )) + } +} + /// Materialize the narrow synthetic-merge case where building the merged /// `onto` tree conflicts before the normal final cherry-pick merge can run. /// @@ -236,7 +257,7 @@ pub fn cherry_pick( /// `ontos` /// The two commits whose full trees should become the merge parents of the result. /// -/// `base_t` +/// `base_tree` /// The already-computed original-base merge outcome for `target`, used to confirm this is the parentless template case. /// /// `target_tree_id` @@ -251,13 +272,13 @@ fn maybe_materialize_conflicted_onto_merge( repo: &gix::Repository, target: &but_core::Commit<'_>, ontos: &[gix::ObjectId], - base_t: &MergeOutcome, + base_tree: &MergeOutcome, target_tree_id: gix::ObjectId, tree_merge_mode: TreeMergeMode, sign_commit: SignCommit, ) -> Result> { let empty_tree = gix::ObjectId::empty_tree(repo.object_hash()); - if !matches!(base_t, MergeOutcome::NoCommit) + if !matches!(base_tree, MergeOutcome::NoCommit) || target.is_conflicted() || !target.parents.is_empty() || target_tree_id != empty_tree @@ -275,35 +296,16 @@ fn maybe_materialize_conflicted_onto_merge( .tree_id_or_auto_resolution()? .detach(); - let mut outcome = repo.merge_trees( - base_tree_id, - ours_tree_id, - theirs_tree_id, - repo.default_merge_labels(), - repo.merge_options_force_ours()?, - )?; - let tree_id = outcome.tree.write()?; - let conflict_kind = gix::merge::tree::TreatAsUnresolved::forced_resolution(); - if !outcome.has_unresolved_conflicts(conflict_kind) { - return Ok(Some(CherryPickOutcome::Commit( - commit_from_unconflicted_tree(ontos, target.clone(), tree_id, sign_commit)?.detach(), - ))); - } - - let conflicted_commit = commit_from_conflicted_tree( - ontos, + merge_into_outcome( + repo, target.clone(), - tree_id, - outcome, - conflict_kind, + ontos, base_tree_id, ours_tree_id, theirs_tree_id, sign_commit, - )?; - Ok(Some(CherryPickOutcome::ConflictedCommit( - conflicted_commit.detach(), - ))) + ) + .map(Some) } #[derive(Debug, Clone)] @@ -356,21 +358,20 @@ fn merged_tree_from_commits( // Handle the case where no commits are given. return Ok(MergeOutcome::NoCommit); }; - let mut sum = find_real_tree(&but_core::Commit::from_id(first.attach(repo))?, preference)?; + let mut merged_tree = + find_real_tree(&but_core::Commit::from_id(first.attach(repo))?, preference)?; + // `None` on the inside means the merged commits share no common ancestor; + // that sticks for the rest of the fold. let mut base: Option> = None; while let Some(commit) = to_merge.pop() { - if let Some(base_commit) = base { - if let Some(base_commit) = base_commit { - base = Some(merge_base(repo, base_commit, commit)?); - } - } else { - base = Some(merge_base(repo, first, commit)?); - } - let Some(base) = base else { - bail!("BUG: Base is None, this should never happen"); + let next_base = match base { + None => merge_base(repo, first, commit)?, + Some(Some(prior)) => merge_base(repo, prior, commit)?, + Some(None) => None, }; + base = Some(next_base); let commit = but_core::Commit::from_id(commit.attach(repo))?; let tree = find_real_tree(&commit, preference)?; @@ -382,8 +383,8 @@ fn merged_tree_from_commits( }; let mut output = repo.merge_trees( - peel_to_tree_or_empty(repo, base)?, - sum, + peel_to_tree_or_empty(repo, next_base)?, + merged_tree, tree, repo.default_merge_labels(), options, @@ -395,10 +396,10 @@ fn merged_tree_from_commits( }); } - sum = output.tree.write()?; + merged_tree = output.tree.write()?; } - Ok(MergeOutcome::Success(sum.detach())) + Ok(MergeOutcome::Success(merged_tree.detach())) } fn merge_base( diff --git a/crates/but-rebase/src/graph_rebase/commit.rs b/crates/but-rebase/src/graph_rebase/commit.rs index 9c9a8b8729d..05cefa99a7c 100644 --- a/crates/but-rebase/src/graph_rebase/commit.rs +++ b/crates/but-rebase/src/graph_rebase/commit.rs @@ -7,13 +7,10 @@ use gix::prelude::ObjectIdExt; use crate::{ commit::{DateMode, create}, - graph_rebase::{ - Editor, Pick, Selector, Step, ToCommitSelector, ToReferenceSelector, - util::collect_ordered_parents, - }, + graph_rebase::{Editor, Selector, ToCommitSelector, ToReferenceSelector}, }; -impl Editor<'_, '_, M> { +impl Editor<'_, M> { /// Returns a reference to the in-memory repository. pub fn repo(&self) -> &gix::Repository { &self.repo @@ -47,13 +44,11 @@ impl Editor<'_, '_, M> { &self, selector: impl ToCommitSelector, ) -> Result<(Selector, but_core::CommitOwned)> { - let selector = self - .history - .normalize_selector(selector.to_commit_selector(self)?)?; - let Step::Pick(Pick { id, .. }) = &self.graph[selector.id] else { + let selector = selector.to_commit_selector(self)?; + let Some(id) = self.graph.commit_id(selector.id) else { bail!("BUG: Expected pick step from commit selector. This should never happen"); }; - Ok((selector, self.find_commit(*id)?)) + Ok((selector, self.find_commit(id)?)) } /// Finds the first pick parent of a reference @@ -61,20 +56,17 @@ impl Editor<'_, '_, M> { &self, selector: impl ToReferenceSelector, ) -> Result<(Selector, but_core::CommitOwned)> { - let selector = self - .history - .normalize_selector(selector.to_reference_selector(self)?)?; + let selector = selector.to_reference_selector(self)?; - let parents = collect_ordered_parents(&self.graph, selector.id); - let first_parent = parents - .first() - .context("Failed to find a parent for selected reference in the step graph.")?; + let first_parent = + crate::graph_rebase::positions::resolve_to_pick(&self.graph, selector.id) + .context("Failed to find a parent for selected reference in the commit graph.")?; - let Step::Pick(pick) = &self.graph[*first_parent] else { - bail!("BUG: collect_ordered_parents provided a non-pick return value"); + let Some(id) = self.graph.commit_id(first_parent) else { + bail!("BUG: first_ordered_parent provided a non-pick return value"); }; - Ok((self.new_selector(*first_parent), self.find_commit(pick.id)?)) + Ok((self.new_selector(first_parent), self.find_commit(id)?)) } /// Writes a commit with correct signing to the in memory repository. diff --git a/crates/but-rebase/src/graph_rebase/creation.rs b/crates/but-rebase/src/graph_rebase/creation.rs index 0cddbcce6e1..475f3de0154 100644 --- a/crates/but-rebase/src/graph_rebase/creation.rs +++ b/crates/but-rebase/src/graph_rebase/creation.rs @@ -1,13 +1,20 @@ -use std::collections::{HashMap, HashSet}; +//! Editor creation: turning a display graph into an editor (see the lifecycle in +//! [`but_graph::ref_layout`]). The arena is cloned wholesale โ€” full commit payloads +//! survive, which putting the arena back after a rebase depends on โ€” then normalized: +//! every parent edge must point at a node in the graph, and the workspace commit gets the +//! parents its chains say it has. Each commit becomes a pick; a pick is mutable when its +//! commit is reachable from `HEAD`, extended by flooding from any extra mutable refs. The +//! reference table is a straight copy of the stored layout with commit ids mapped to pick +//! handles; nothing is re-derived on the way in. -use anyhow::{Result, bail}; -use but_core::{RefMetadata, commit::SignCommit}; -use but_graph::{Commit, SegmentIndex}; -use petgraph::{Direction, visit::EdgeRef as _}; +use std::collections::HashSet; +use anyhow::{Result, anyhow, bail}; +use but_core::{RefMetadata, commit::SignCommit, ref_metadata::ProjectMeta}; + +use crate::graph_rebase::graph_editor::{EditorIndex, GroupCarry, PickIndex, RefGroup, RefIndex}; use crate::graph_rebase::{ - Checkout, Edge, Editor, Pick, RevisionHistory, Selector, Step, StepGraph, StepGraphIndex, - SuccessfulRebase, util, + Checkout, Editor, GraphEditor, Pick, RevisionHistory, Selector, SuccessfulRebase, }; #[derive(Clone)] @@ -15,12 +22,12 @@ use crate::graph_rebase::{ pub struct GraphEditorOptions { /// Determines how cherry-picked commits are signed. pub default_sign_commit: SignCommit, - /// References whose segment should be forced mutable. + /// References to force mutable. /// - /// The editor always contains every segment in the workspace graph, with - /// only those reachable from `HEAD` being mutable. Use this to force a - /// segment that isn't reachable from `HEAD` to be mutable so it can be - /// rewritten. + /// The editor always contains every commit and ref the workspace graph + /// carries, with only those reachable from `HEAD` being mutable. Use this + /// to force a ref that isn't reachable from `HEAD` to be mutable so its + /// territory can be rewritten. pub extra_mutable_refs: Vec, } @@ -33,311 +40,260 @@ impl Default for GraphEditorOptions { } } -/// Creates an editor out of the workspace graph. -impl<'ws, 'meta, M: RefMetadata> Editor<'ws, 'meta, M> { - /// Creates an editor out of the workspace graph with the default options. +/// Creates an editor out of the commit graph. +impl<'meta, M: RefMetadata> Editor<'meta, M> { + /// Creates an editor out of the commit graph with the default options. + /// + /// The commit graph must carry the ref layout the builder stores on it. pub fn create( - workspace: &'ws mut but_graph::Workspace, + commit_graph: &but_graph::CommitGraph, + project_meta: &ProjectMeta, meta: &'meta mut M, repo: &gix::Repository, ) -> Result { - Self::create_with_opts(workspace, meta, repo, &GraphEditorOptions::default()) + Self::create_with_opts( + commit_graph, + project_meta, + meta, + repo, + &GraphEditorOptions::default(), + ) } - /// Creates an editor out of the workspace graph with the specified options. + /// Creates an editor out of the commit graph with the specified options. pub fn create_with_opts( - workspace: &'ws mut but_graph::Workspace, + commit_graph: &but_graph::CommitGraph, + project_meta: &ProjectMeta, meta: &'meta mut M, repo: &gix::Repository, options: &GraphEditorOptions, ) -> Result { - // This first creates runs of nodes and associates them with the - // but-graph segments. We then do a second pass over all the segments - // and use the but_graph to connect up the runs. Finally, we validate - // that each Pick step's parents match the commit's actual parents, - // and if not, we disconnect and rewire directly to the correct - // parent commits. + // Not #[instrument]: its generated closure cannot return the `'meta` borrow. + let _span = tracing::debug_span!("Editor::create").entered(); + let (graph, references, checkouts) = build_graph_editor(commit_graph, options)?; + Ok(Self { + graph, + initial_references: references, + checkouts, + repo: repo.clone().with_object_memory(), + history: RevisionHistory::new(), + project_meta: project_meta.clone(), + meta, + }) + } +} - // TODO(CTO): Look into traversing "in workspace" segments that are not - // reachable from the entrypoint TODO(CTO): Look into stopping at the - // common base - let entrypoint = workspace.graph.entrypoint()?; +/// Build the editor graph from the data the builder stores on the +/// [`but_graph::CommitGraph`] โ€” no segment is read; the module doc spells the ingest contract. +fn build_graph_editor( + cg: &but_graph::CommitGraph, + options: &GraphEditorOptions, +) -> Result<(GraphEditor, Vec, Vec)> { + let Some(stored) = cg.layout() else { + bail!("editor creation requires the ref layout the builder stores on the CommitGraph"); + }; + + let workspace_commit_id = stored.materialized_ws_parents.as_ref().map(|m| m.commit); + + // A parent outside the graph means the traversal was partial here: the editor's parent list + // must all be present, so those parent numbers are dropped โ€” the raw parent list is preserved so + // the rebase keeps the commit's real ancestry. + let traversal_was_partial_at = |id: gix::ObjectId| { + let raw_parents = &cg.node(id).expect("iterating graph ids").parent_ids; + (!raw_parents.is_empty() && raw_parents.iter().any(|p| cg.node(*p).is_none())) + .then(|| raw_parents.clone()) + }; + let node_of = |id: gix::ObjectId| -> Result { + cg.index_of(id) + .map(PickIndex) + .ok_or_else(|| anyhow!("stored position {id} is not a commit in the graph")) + }; + + let mut arena = cg.clone(); + for (i, id) in cg.commit_ids().enumerate() { + // The workspace commit takes its chain parents from the stored layout (duplicates + // and all); everything else keeps its present parents in order. + if workspace_commit_id == Some(id) { + let chain_parents = stored + .materialized_ws_parents + .as_ref() + .map(|m| m.parents.as_slice()) + .unwrap_or_default(); + let mut targets = Vec::new(); + for parent in chain_parents { + let Some(target) = cg.index_of(*parent) else { + bail!("stored ws parent {parent} is not a commit in the graph"); + }; + targets.push(target); + } + arena.set_parents(i, targets); + } else if traversal_was_partial_at(id).is_some() { + arena.set_parents(i, cg.present_parent_indices(i)); + } + } - let mut mutable_entrypoints = vec![entrypoint.segment.id]; + // Mutability follows reachability: the stored entrypoint reach, extended below by + // flooding from the extra mutable refs once the layout is ingested. + let mut mutable_commits: HashSet = + stored.reachable_commits.iter().copied().collect(); + + let mut step_graph = GraphEditor::adopt(arena); + // Remote-category refs are never mutable โ€” the editor cannot move or delete the remote. + let ref_mutable: Vec = stored + .facts + .iter() + .map(|(name, facts)| { + facts.reachable && name.as_ref().category() != Some(gix::refs::Category::RemoteBranch) + }) + .collect(); + // Register every reference (facts order = table order), then copy the stored groups + // in: the layout's shape IS the editor's shape, so ingest is an id-mapping copy โ€” + // commit ids become pick handles, everything else transfers verbatim. + let ref_ixs: Vec = stored + .facts + .iter() + .zip(&ref_mutable) + .map(|((name, _), mutable)| step_graph.add_reference(name.clone(), *mutable)) + .collect(); + for ((_, facts), &entry) in stored.facts.iter().zip(&ref_ixs) { + step_graph.set_ref_ambiguous(entry, facts.ambiguous); + } + for (on, commit_groups) in &stored.groups { + let key = node_of(*on)?; + let groups = commit_groups + .iter() + .map(|group| { + Ok(RefGroup { + members: group.members.clone(), + carry: match &group.carry { + but_graph::ref_layout::GroupCarry::None => GroupCarry::None, + but_graph::ref_layout::GroupCarry::All => GroupCarry::All, + but_graph::ref_layout::GroupCarry::Edges(edges) => GroupCarry::Edges( + edges + .iter() + .map(|&(id, parent_number)| Ok((node_of(id)?, parent_number))) + .collect::>>()?, + ), + }, + attach: group.attach.clone(), + }) + }) + .collect::>>()?; + step_graph.insert_groups(key, groups); + } + // The extra-mutable flood, over the editor's own queries: down below-links and onto + // the ref's commit, then per parent edge across the refs that carry it. Remote refs + // are traversed but never marked (the category gate above). + if !options.extra_mutable_refs.is_empty() { + let mut queue: Vec = Vec::new(); for ref_name in &options.extra_mutable_refs { - let Some((segment, _)) = workspace - .graph - .segment_and_commit_by_ref_name(ref_name.as_ref()) - else { - bail!("Failed to find corresponding segment for {ref_name}"); + let Some(entry) = step_graph.entry_of(ref_name.as_ref()) else { + bail!("Failed to find corresponding reference for {ref_name}"); }; - mutable_entrypoints.push(segment.id); - } - - // Segments reachable from a mutable entrypoint (following parent edges) - // may be rewritten. Every other segment is still included in the - // editor, but as immutable. - let mut mutable_segments = HashSet::new(); - for entrypoint in mutable_entrypoints { - workspace.graph.visit_all_segments_including_start_until( - entrypoint, - Direction::Outgoing, - |segment| !mutable_segments.insert(segment.id), - ); - } - - // The editor contains every commit the graph contains, so we iterate - // over all segments rather than only those reachable from an entrypoint. - let segments_to_add = workspace.graph.segments().collect::>(); - - let workspace_commit_id = workspace - .graph - .managed_entrypoint_commit(repo)? - .map(|c| c.id); - - let mut commits: Vec = vec![]; - let mut commit_to_idx = HashMap::::new(); - let mut commit_to_pick_ix = HashMap::::new(); - let mut graph = StepGraph::new(); - let mut head_selectors = vec![]; - let mut references = vec![]; - struct NodeSegment { - nodes: Vec, + queue.push(entry.into()); } - - let mut segments = HashMap::::new(); - - for sid in segments_to_add { - let segment = &workspace.graph[sid]; - let mutable = mutable_segments.contains(&sid); - let mut nodes = vec![]; - - if let Some(reference) = segment.ref_name() { - let refname = reference.to_owned(); - // Only mutable references are tracked for potential deletion. - if mutable { - references.push(refname.clone()); - } - let ix = graph.add_node(Step::Reference { - refname: refname.clone(), - mutable, - }); - if Some(reference) == entrypoint.segment.ref_name() { - head_selectors.push(Selector { - id: ix, - revision: 0, - }); - } - nodes.push(ix); - } - - for commit in &segment.commits { - commits.push(commit.clone()); - commit_to_idx.insert(commit.id, segment.id); - - let refs = commit - .refs - .iter() - .map(|r| r.ref_name.clone()) - .collect::>(); - - for reference in refs { - if mutable { - references.push(reference.to_owned()); + let mut seen_refs = HashSet::new(); + let mut seen_picks = HashSet::new(); + while let Some(visit) = queue.pop() { + match (visit.as_ref(), visit.as_pick()) { + (Some(entry), _) => { + if !seen_refs.insert(entry) { + continue; } - let ix = graph.add_node(Step::Reference { - refname: reference.clone(), - mutable, + let is_remote = step_graph.state_of(entry.into()).is_some_and(|state| { + state.refname.category() == Some(gix::refs::Category::RemoteBranch) }); - if let Some(previous_ix) = nodes.last() { - graph.add_edge(*previous_ix, ix, Edge { order: 0 }); + if !is_remote { + step_graph.set_ref_mutable(entry, true); + } + if let Some(below) = step_graph.below_of(entry) { + queue.push(below.into()); + } + if let Some(on) = step_graph.positioned_on(entry) { + queue.push(on.into()); } - nodes.push(ix); } - - let mut pick = if workspace_commit_id == Some(commit.id) { - Pick::new_workspace_pick(commit.id) - } else { - let mut pick = Pick::new_pick(commit.id); - pick.sign_commit = options.default_sign_commit; - pick - }; - pick.mutable = mutable; - let ix = graph.add_node(Step::Pick(pick)); - commit_to_pick_ix.insert(commit.id, ix); - if let Some(previous_ix) = nodes.last() { - graph.add_edge(*previous_ix, ix, Edge { order: 0 }); + (_, Some(pick)) => { + if !seen_picks.insert(pick) { + continue; + } + if let Some(id) = step_graph.commit_id(pick) { + mutable_commits.insert(id); + } + for (parent_number, parent) in step_graph.parents(pick).into_iter().enumerate() + { + let carriers: Vec = step_graph + .edge_carriers(parent, pick, parent_number) + .collect(); + for carrier in carriers { + queue.push(carrier.into()); + } + queue.push(parent.into()); + } } - nodes.push(ix); - } - - if nodes.is_empty() { - tracing::debug!("Empty node added - this is probably impossible"); - let ix = graph.add_node(Step::None); - nodes.push(ix); - } - - segments.insert(segment.id, NodeSegment { nodes }); - } - - let commit_ids = commits.iter().map(|c| c.id).collect::>(); - - for c in &commits { - let has_no_parents = c.parent_ids.is_empty(); - let missing_parent_steps = c.parent_ids.iter().any(|p| !commit_ids.contains(p)); - - // If the commit has parents, but at least one of them is not - // in the graph, this means but-graph did a partial traversal - // and we want to preserve the commit as it is. - if !has_no_parents && missing_parent_steps { - let Some(idx) = commit_to_pick_ix.get(&c.id) else { - bail!("BUG: Listed commit does not have corresponding idx."); - }; - - let Step::Pick(pick) = &mut graph[*idx] else { - bail!("BUG: Listed commit does not have corresponding pick step."); - }; - - pick.preserved_parents = Some(c.parent_ids.clone()); - }; - } - - for sidx in segments.keys() { - let Some(source) = segments.get(sidx).and_then(|n| n.nodes.last()) else { - continue; - }; - - let edges = { - let mut v = workspace - .graph - .edges_directed(*sidx, Direction::Outgoing) - .collect::>(); - // TODO: the code below relies on edges being in reversed order, - // but that changed now and they are in commit-graph order. - // This is the minimal change to make this work, - // even though a second step should be the cleanup of the - // whole ordering business which also compensated for out-of-order - // edges (which is also fixed). - v.reverse(); - v - }; - 'inner: for edge in edges { - let Some(target) = segments.get(&edge.target()).and_then(|n| n.nodes.first()) - else { - tracing::warn!( - "Dropping parent edge for segment {sidx:?}: edge target {:?} has no nodes", - edge.target() - ); - continue 'inner; - }; - - // TODO: does it have relevance when `parent_order()` is `None` for edges to virtual segments? - let order = edge.weight().parent_order().unwrap_or(0) as usize; - graph.add_edge(*source, *target, Edge { order }); + _ => {} } } + } - for c in &commits { - if Some(c.id) == workspace_commit_id { - continue; - } - - let Some(&pick_ix) = commit_to_pick_ix.get(&c.id) else { - continue; - }; - - // Skip commits with preserved parents (partial traversal โ€” already handled above) - if let Step::Pick(Pick { - preserved_parents: Some(_), - .. - }) = &graph[pick_ix] - { - continue; - } - - // Resolve what the graph thinks are the parents of this pick - let graph_parents = util::collect_ordered_parents(&graph, pick_ix); - let graph_parent_ids: Vec = graph_parents - .iter() - .filter_map(|idx| match &graph[*idx] { - Step::Pick(Pick { id, .. }) => Some(*id), - _ => None, - }) - .collect(); - - if graph_parent_ids == c.parent_ids { - continue; - } - - tracing::warn!( - "but-graph inconsistent with the commit graph.\nParents for commit {} do not match.\n\nFound:{:?}\nExpected:{:?}\n\nThese IDs may be in memory, but may be helpful for debugging.", - c.id, - graph_parent_ids - .iter() - .map(|p| p.to_string()) - .collect::>(), - c.parent_ids - .iter() - .map(|p| p.to_string()) - .collect::>(), - ); - - let outgoing_edge_ids: Vec<_> = graph - .edges_directed(pick_ix, Direction::Outgoing) - .map(|e| e.id()) - .collect(); - for edge_id in outgoing_edge_ids { - graph.remove_edge(edge_id); - } - - 'inner: for (order, parent_id) in c.parent_ids.iter().enumerate() { - let Some(&target_ix) = commit_to_pick_ix.get(parent_id) else { - tracing::warn!( - "Dropping parent edge for commit {} (parent fix): parent {parent_id} not found in pick map", - c.id - ); - continue 'inner; - }; - - graph.add_edge(pick_ix, target_ix, Edge { order }); - } + for (i, id) in cg.commit_ids().enumerate() { + let ix = PickIndex(i); + let mut pick = if workspace_commit_id == Some(id) { + Pick::new_workspace_pick(id) + } else { + let mut pick = Pick::new_pick(id); + pick.sign_commit = options.default_sign_commit; + pick + }; + pick.mutable = mutable_commits.contains(&id); + step_graph.set_step(ix, Some(pick)); + if let Some(raw_parents) = traversal_was_partial_at(id) { + step_graph.set_preserved_parents(ix, Some(raw_parents)); } + } - Ok(Self { - graph, - initial_references: references, - // TODO(CTO): We need to eventually list all worktrees that we own - // here so we can `safe_checkout` them too. - checkouts: head_selectors - .into_iter() - .map(|selector| Checkout::Head { - selector, - merge_base_override: None, - }) - .collect(), - repo: repo.clone().with_object_memory(), - history: RevisionHistory::new(), - workspace, - meta, + let references = ref_ixs + .iter() + .filter(|&&entry| { + step_graph + .state_of(entry.into()) + .is_some_and(|state| state.mutable) }) - } + .filter_map(|&entry| { + step_graph + .state_of(entry.into()) + .map(|state| state.refname.clone()) + }) + .collect(); + let checkouts = stored + .head_refs + .iter() + .filter_map(|name| step_graph.entry_of(name.as_ref())) + .map(|entry| Checkout::Head { + selector: Selector { id: entry.into() }, + merge_base_override: None, + }) + .collect(); + + crate::graph_rebase::positions::debug_assert_positions_total(&step_graph); + Ok((step_graph, references, checkouts)) } -impl<'ws, 'meta, M: RefMetadata> SuccessfulRebase<'ws, 'meta, M> { +impl<'meta, M: RefMetadata> SuccessfulRebase<'meta, M> { /// Converts a SuccessfulRebase back into another editor for multi-step operations. /// /// This is the normalization path for callers that want to chain /// additional editor-based operations and need the editor graph plus /// in-memory repository to agree on ancestry. - pub fn into_editor(self) -> Editor<'ws, 'meta, M> { + pub fn into_editor(self) -> Editor<'meta, M> { Editor { graph: self.graph, initial_references: self.initial_references, checkouts: self.checkouts, repo: self.repo, history: self.history, - workspace: self.workspace, + project_meta: self.project_meta, meta: self.meta, } } diff --git a/crates/but-rebase/src/graph_rebase/materialize.rs b/crates/but-rebase/src/graph_rebase/materialize.rs index 1df1e11db0b..9020f3cce16 100644 --- a/crates/but-rebase/src/graph_rebase/materialize.rs +++ b/crates/but-rebase/src/graph_rebase/materialize.rs @@ -9,37 +9,36 @@ use gix::refs::{ transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}, }; -use crate::graph_rebase::{ - Checkout, MaterializeOutcome, Pick, Step, SuccessfulRebase, util::collect_ordered_parents, -}; +use crate::graph_rebase::{Checkout, MaterializeOutcome, Pick, Step, SuccessfulRebase}; -impl<'ws, 'graph, M: RefMetadata> SuccessfulRebase<'ws, 'graph, M> { +impl<'meta, M: RefMetadata> SuccessfulRebase<'meta, M> { /// Materializes a history rewrite - pub fn materialize(mut self) -> Result> { - let repo = self.repo.clone(); + #[tracing::instrument(level = "debug", skip_all, err(Debug))] + pub fn materialize(mut self) -> Result> { if let Some(memory) = self.repo.objects.take_object_memory() { - memory.persist(self.repo)?; + memory.persist(&self.repo)?; } let mut head_reference_update = None; - for checkout in self.checkouts { + for checkout in std::mem::take(&mut self.checkouts) { match checkout { Checkout::Head { selector, merge_base_override, } => { - let selector = self.history.normalize_selector(selector)?; - let step = self.graph[selector.id].clone(); + let step = self.graph.step_view(selector.id); let (new_head, new_head_refname) = match step { Step::None => bail!("Checkout selector is pointing to none"), Step::Pick(Pick { id, .. }) => (id, None), Step::Reference { refname, .. } => { - let parents = collect_ordered_parents(&self.graph, selector.id); - let parent_step_id = - parents.first().context("No first parent to reference")?; - let Step::Pick(Pick { id, .. }) = self.graph[*parent_step_id] else { - bail!("collect_ordered_parents should always return a commit pick"); + let parent_step_id = crate::graph_rebase::positions::resolve_to_pick( + &self.graph, + selector.id, + ) + .context("No commit to reference")?; + let Some(id) = self.graph.commit_id(parent_step_id) else { + bail!("resolve_to_pick should always return a commit pick"); }; (id, Some(refname)) } @@ -50,7 +49,7 @@ impl<'ws, 'graph, M: RefMetadata> SuccessfulRebase<'ws, 'graph, M> { // commit mapping), perform a safe checkout. safe_checkout_from_head( new_head, - &repo, + &self.repo, Options { skip_head_update: true, merge_base_override, @@ -61,9 +60,9 @@ impl<'ws, 'graph, M: RefMetadata> SuccessfulRebase<'ws, 'graph, M> { } } - let mut ref_edits = self.ref_edits.clone(); + let mut ref_edits = std::mem::take(&mut self.ref_edits); if let Some(refname) = head_reference_update - && repo.head_name()?.as_ref() != Some(&refname) + && self.repo.head_name()?.as_ref() != Some(&refname) { let ref_short_name = refname.shorten().to_owned(); ref_edits.push(RefEdit { @@ -84,18 +83,7 @@ impl<'ws, 'graph, M: RefMetadata> SuccessfulRebase<'ws, 'graph, M> { deref: false, }); } - repo.edit_references(ref_edits)?; - - let project_meta = self.workspace.graph.project_meta.clone(); - self.workspace - .refresh_from_head(&repo, &*self.meta, project_meta)?; - - Ok(MaterializeOutcome { - graph: self.graph, - history: self.history, - workspace: self.workspace, - meta: self.meta, - }) + self.finish(ref_edits) } /// Materializes a rebase without performing a checkout. @@ -104,31 +92,26 @@ impl<'ws, 'graph, M: RefMetadata> SuccessfulRebase<'ws, 'graph, M> { /// [`Self::materialize`]. This is intended to be used in niche cases like /// `uncommit`. /// - /// This has means that we don't "cherry pick" the uncommitted changes from - /// the old head onto the new one. - /// - /// If I dropped a commit from the history, - /// [`Self::materialize_without_checkout`] will now see those changes in - /// your working directory. - /// - /// If I instead called [`Self::materialize`], the changes would instead be - /// gone from disk. - pub fn materialize_without_checkout(mut self) -> Result> { - let repo = self.repo.clone(); + /// Skipping the checkout means the uncommitted changes are not carried from + /// the old head to the new one. If you drop a commit from history, this + /// leaves its changes in your working directory; [`Self::materialize`] + /// would remove them from disk. + #[tracing::instrument(level = "debug", skip_all, err(Debug))] + pub fn materialize_without_checkout(mut self) -> Result> { if let Some(memory) = self.repo.objects.take_object_memory() { - memory.persist(self.repo)?; + memory.persist(&self.repo)?; } - repo.edit_references(self.ref_edits.clone())?; + let ref_edits = std::mem::take(&mut self.ref_edits); + self.finish(ref_edits) + } - let project_meta = self.workspace.graph.project_meta.clone(); - self.workspace - .refresh_from_head(&repo, &*self.meta, project_meta)?; + fn finish(self, ref_edits: Vec) -> Result> { + self.repo.edit_references(ref_edits)?; Ok(MaterializeOutcome { graph: self.graph, history: self.history, - workspace: self.workspace, meta: self.meta, }) } diff --git a/crates/but-rebase/src/graph_rebase/merge_commit_changes.rs b/crates/but-rebase/src/graph_rebase/merge_commit_changes.rs index 4ee3015ba40..6efcee363bc 100644 --- a/crates/but-rebase/src/graph_rebase/merge_commit_changes.rs +++ b/crates/but-rebase/src/graph_rebase/merge_commit_changes.rs @@ -5,9 +5,8 @@ use std::collections::{HashMap, HashSet}; use anyhow::{Context, Result, bail}; use but_core::{RefMetadata, commit::tree_expression::TreeExpression}; use gix::prelude::ObjectIdExt; -use petgraph::Direction; -use crate::graph_rebase::{Editor, Pick, Step, StepGraphIndex, util::collect_ordered_parents}; +use crate::graph_rebase::{Editor, PickIndex, util::collect_ordered_parents}; /// A selected commit change range that should be merged into an accumulated /// tree. @@ -47,7 +46,7 @@ pub struct MergeCommitChangesConflict { pub conflict_entries: but_core::commit::ConflictEntries, } -impl Editor<'_, '_, M> { +impl Editor<'_, M> { /// Return the tree produced by preserving the target commit's full tree /// and then merging only the surviving selected commits' own change /// ranges into it. @@ -107,8 +106,8 @@ impl Editor<'_, '_, M> { /// Turn the selected commits into mergeable `base..commit` change ranges /// relative to a target commit. /// - /// This planner assumes the editor step graph and the editor's in-memory - /// repository describe the same commit topology. The step graph is used to + /// This planner assumes the editor commit graph and the editor's in-memory + /// repository describe the same commit topology. The commit graph is used to /// traverse selected commits deterministically and to find the target's /// ancestry cone quickly, while first-parent/base semantics still come /// from the commit objects stored in the in-memory repository. @@ -230,35 +229,32 @@ enum TraversalMode { /// [SelectedCommitPlanningTraversal::ordered_selected_commit_ids] is /// deterministic and based solely on the graph. /// -/// This walk intentionally uses the editor step graph only as a traversal +/// This walk intentionally uses the editor commit graph only as a traversal /// structure. Parent/tree semantics are still read from the in-memory /// repository commits, so callers must only use this planner when the editor /// graph and the in-memory repository represent the same commit topology. fn traverse_graph_for_planning( - editor: &Editor<'_, '_, M>, + editor: &Editor<'_, M>, target_commit_id: gix::ObjectId, selected_commit_ids: &HashSet, ) -> Result { let mut traversal = SelectedCommitPlanningTraversal::default(); let mut seen_selected_commit_ids = HashSet::new(); - let mut seen_normal = HashSet::::new(); - let mut seen_target_ancestor_walk = HashSet::::new(); + let mut seen_normal = HashSet::::new(); + let mut seen_target_ancestor_walk = HashSet::::new(); - let mut roots = editor - .graph - .externals(Direction::Incoming) - .collect::>(); - roots.sort_unstable_by_key(|idx| idx.index()); + let mut roots = editor.graph.tips().collect::>(); + roots.sort_unstable(); for root in roots { let mut stack = vec![(root, false, TraversalMode::Normal)]; - while let Some((node, expanded, mode)) = stack.pop() { + while let Some((entry, expanded, mode)) = stack.pop() { match mode { TraversalMode::Normal => { if expanded { - if let Step::Pick(Pick { id, .. }) = editor.graph[node] { + if let Some(id) = editor.graph.commit_id(entry) { if let Some(first_parent_metadata) = - get_first_parent_metadata(editor, id, selected_commit_ids)? + first_parent_metadata(editor, id, selected_commit_ids)? { traversal .first_parent_metadata_by_selected_commit_id @@ -270,7 +266,7 @@ fn traverse_graph_for_planning( if id == target_commit_id { traversal.target_ancestor_commit_ids.insert(id); - for parent_idx in collect_ordered_parents(&editor.graph, node) { + for parent_idx in collect_ordered_parents(&editor.graph, entry) { stack.push(( parent_idx, false, @@ -282,26 +278,26 @@ fn traverse_graph_for_planning( continue; } - if !seen_normal.insert(node) { + if !seen_normal.insert(entry) { continue; } - let parents = collect_ordered_parents(&editor.graph, node); - stack.push((node, true, TraversalMode::Normal)); + let parents = collect_ordered_parents(&editor.graph, entry); + stack.push((entry, true, TraversalMode::Normal)); for parent_idx in parents.into_iter() { stack.push((parent_idx, false, TraversalMode::Normal)); } } TraversalMode::MarkTargetAncestors => { - if !seen_target_ancestor_walk.insert(node) { + if !seen_target_ancestor_walk.insert(entry) { continue; } - if let Step::Pick(Pick { id, .. }) = editor.graph[node] { + if let Some(id) = editor.graph.commit_id(entry) { traversal.target_ancestor_commit_ids.insert(id); } - for parent_idx in collect_ordered_parents(&editor.graph, node) { + for parent_idx in collect_ordered_parents(&editor.graph, entry) { stack.push((parent_idx, false, TraversalMode::MarkTargetAncestors)); } } @@ -315,14 +311,14 @@ fn traverse_graph_for_planning( /// Returns first-parent metadata if `commit_id` is selected. /// /// This intentionally reads the commit object from the editor's in-memory -/// repository instead of inferring first-parent semantics from the step graph. -/// The step graph only provides deterministic traversal order and fast target +/// repository instead of inferring first-parent semantics from the commit graph. +/// The commit graph only provides deterministic traversal order and fast target /// ancestry discovery. The actual `base..commit` merge ranges must match the /// commit DAG that owns the trees and SHAs being merged, so callers are -/// expected to keep the editor step graph aligned with that in-memory +/// expected to keep the editor commit graph aligned with that in-memory /// repository topology. -fn get_first_parent_metadata( - editor: &Editor<'_, '_, M>, +fn first_parent_metadata( + editor: &Editor<'_, M>, commit_id: gix::ObjectId, selected_commit_ids: &HashSet, ) -> Result> { @@ -350,7 +346,7 @@ fn get_first_parent_metadata( /// If the first parent link cannot be followed due to a commit not having any /// parents, the empty tree is returned. fn base_tree_id_for_emitted_tip( - editor: &Editor<'_, '_, M>, + editor: &Editor<'_, M>, tip_commit_id: gix::ObjectId, selected_metadata_by_commit: &HashMap, target_ancestor_commit_ids: &HashSet, @@ -384,7 +380,7 @@ fn base_tree_id_for_emitted_tip( } fn tree_id_for_commit( - editor: &Editor<'_, '_, M>, + editor: &Editor<'_, M>, commit_id: gix::ObjectId, ) -> Result { Ok(but_core::Commit::from_id(commit_id.attach(&editor.repo))? diff --git a/crates/but-rebase/src/graph_rebase/mod.rs b/crates/but-rebase/src/graph_rebase/mod.rs index 34a2448b63b..041e29e92d9 100644 --- a/crates/but-rebase/src/graph_rebase/mod.rs +++ b/crates/but-rebase/src/graph_rebase/mod.rs @@ -2,21 +2,33 @@ //! One graph based engine to rule them all, //! one vector based to find them, //! one mess of git2 code to bring them all, -//! and in the darknes bind them. +//! and in the darkness bind them. +//! +//! --- +//! +//! A graph-based rebase engine. The workspace is loaded into an `Editor` as a `GraphEditor`: an +//! arena of `Step`s where a `Pick` is a commit to cherry-pick, while a `Reference` (a branch) +//! rides as positioned data on a pick. +//! Callers mutate the graph (insert/move/remove picks, create/move references), then +//! `Editor::rebase` replays it โ€” cherry-picking every mutable pick onto its new parents and +//! producing the reference updates to write. +//! +//! References are POSITIONS, not entries with edges โ€” see the `positions` module for the model. mod creation; +mod graph_editor; +mod positions; pub mod rebase; +mod ref_ops; pub mod traverse; -use std::collections::{BTreeMap, HashMap}; +use std::collections::BTreeMap; -use anyhow::{Context, Result, bail}; -use but_core::{RefMetadata, commit::SignCommit}; -use but_graph::init::Overlay; +use anyhow::{Context as _, Result, bail}; +use but_core::{RefMetadata, commit::SignCommit, ref_metadata::ProjectMeta}; +use but_graph::walk::Overlay; pub use creation::GraphEditorOptions; use gix::refs::transaction::RefEdit; -use crate::graph_rebase::util::collect_ordered_parents; - use crate::graph_rebase::cherry_pick::{PickMode, TreeMergeMode}; pub mod cherry_pick; pub mod commit; @@ -24,6 +36,7 @@ pub mod materialize; pub mod merge_commit_changes; pub mod mutate; pub mod ordering; +pub mod selector; pub(crate) mod util; pub mod workspace; pub use workspace::{GraphWorkspace, Subgraph}; @@ -31,6 +44,225 @@ pub use workspace::{GraphWorkspace, Subgraph}; /// Utilities for testing pub mod testing; +/// Used to manipulate a set of picks. +#[derive(Debug)] +pub struct Editor<'meta, M: RefMetadata> { + /// The internal graph of steps + graph: GraphEditor, + /// Initial references, used to spot references that need deleting. + initial_references: Vec, + /// Worktrees that we might need to perform `safe_checkout` on. + checkouts: Vec, + /// The in-memory repository that the rebase engine works with. + repo: gix::Repository, + /// Provides data about how the editor instance was transformed. + history: RevisionHistory, + /// The workspace target configuration the editor was created with. + project_meta: ProjectMeta, + /// A reference to the metadata that the editor was created for. + meta: &'meta mut M, +} + +impl<'meta, M: RefMetadata> Editor<'meta, M> { + pub(crate) fn new_selector(&self, id: impl Into) -> Selector { + let id = id.into(); + Selector { id } + } +} + +/// Represents a successful rebase, and any valid, but potentially conflicting scenarios it had. +#[derive(Debug)] +pub struct SuccessfulRebase<'meta, M: RefMetadata> { + pub(crate) repo: gix::Repository, + pub(crate) initial_references: Vec, + /// Any reference edits that need to be committed as a result of the history + /// rewrite + pub(crate) ref_edits: Vec, + /// The new commit graph + pub(crate) graph: GraphEditor, + pub(crate) checkouts: Vec, + /// Provides data about how the editor instance was transformed. + pub history: RevisionHistory, + /// The workspace target configuration the editor was created with. + project_meta: ProjectMeta, + /// A reference to the metadata that the editor was created for. + meta: &'meta mut M, +} + +impl<'meta, M: RefMetadata> SuccessfulRebase<'meta, M> { + /// Returns the in-memory repository that backs this rebase preview. + /// + /// This repository may contain objects that have not been persisted yet, + /// which makes it suitable for dry-run inspection of a [`Self::rebase_overlay`] redo. + pub fn repo(&self) -> &gix::Repository { + &self.repo + } + + /// Returns the preview repository together with mutable access to the + /// ref-metadata the editor was created with. + /// + /// Use this to build post-rebase projections that need both, like a + /// workspace preview computed from [`Self::rebase_overlay`]. + pub fn repo_and_meta_mut(&mut self) -> (&gix::Repository, &mut M) { + (&self.repo, self.meta) + } + + /// Returns the ref-metadata the editor was created with. + pub fn meta(&self) -> &M { + self.meta + } + + /// The mutated commit graph this rebase will materialize โ€” already the next workspace + /// state, since materializing only persists objects and applies ref edits. Project it + /// (with [`Self::repo`] and [`Self::rebase_overlay`]) to preview without a rewalk. + pub fn arena(&self) -> &but_graph::CommitGraph { + self.graph.arena() + } + + /// Return the commit targeted by `ref_name` in the post-rebase graph. + pub fn reference_target(&self, ref_name: &gix::refs::FullNameRef) -> Result { + let (ref_idx, ..) = self + .graph + .references() + .find(|(_, refname, _)| refname.as_ref() == ref_name) + .with_context(|| format!("Could not find reference '{ref_name}' in rebase result"))?; + crate::graph_rebase::positions::resolve_to_pick(&self.graph, ref_idx) + .and_then(|pick| self.graph.commit_id(pick)) + .context("Reference has no target commit in rebase result") + } + + /// The overlay describing this rebase's outcome: updated/dropped refs plus the requested + /// checkout as the entrypoint. + /// + /// Feed it to a graph or workspace redo (with [`Self::repo`], since rewritten objects may + /// exist only in memory) to preview the post-rebase state without materializing. + pub fn rebase_overlay(&self) -> Result { + self.rebase_overlay_with_workspace_overrides(None, None) + } + + /// Like [`Self::rebase_overlay`], with ad-hoc workspace projection overrides. + /// + /// For dry-run operations whose graph rewrite is accompanied by metadata or checkout + /// changes that are deliberately not persisted: the override `entrypoint` names the + /// commit and local reference that would be checked out, while `branch_stack_order` + /// supplies the tip-to-base order that would be written to ref metadata. + pub fn rebase_overlay_with_workspace_overrides( + &self, + entrypoint: Option<(gix::ObjectId, gix::refs::FullName)>, + branch_stack_order: Option<&[gix::refs::FullName]>, + ) -> Result { + let dropped_refs = self.ref_edits.iter().filter_map(|edit| match &edit.change { + gix::refs::transaction::Change::Delete { .. } => Some(edit.name.clone()), + _ => None, + }); + let updated_refs = self.ref_edits.iter().filter_map(|edit| match &edit.change { + gix::refs::transaction::Change::Update { new, .. } => Some(gix::refs::Reference { + name: edit.name.clone(), + target: new.clone(), + // TODO(CTO): Peeled is only relevant for symbolic refs? + peeled: None, + }), + _ => None, + }); + + let Some((entrypoint_id, entrypoint_refname)) = self + .checkouts + .iter() + .filter_map(|checkout| match checkout { + Checkout::Head { selector, .. } => match self.graph.step_view(selector.id) { + Step::None => None, + Step::Pick(Pick { id, .. }) => Some((id, None)), + Step::Reference { refname, .. } => { + if let Some(to_reference) = crate::graph_rebase::positions::resolve_to_pick( + &self.graph, + selector.id, + ) && let Some(id) = self.graph.commit_id(to_reference) + { + Some((id, Some(refname))) + } else { + None + } + } + }, + }) + .next() + else { + bail!("BUG: Tried to construct rebase engine graph overlay with no entrypoints"); + }; + + let (entrypoint_id, entrypoint_refname) = entrypoint + .map_or((entrypoint_id, entrypoint_refname), |(id, ref_name)| { + (id, Some(ref_name)) + }); + let mut overlay = Overlay::default() + .with_references(updated_refs) + .with_dropped_references(dropped_refs) + .with_entrypoint(entrypoint_id, entrypoint_refname); + if let Some(branch_stack_order) = branch_stack_order { + overlay = overlay.with_branch_stack_order_override(branch_stack_order.iter().cloned()); + } + Ok(overlay) + } +} + +/// The outcome of a materialize +#[derive(Debug)] +pub struct MaterializeOutcome<'meta, M: RefMetadata> { + pub(crate) graph: GraphEditor, + /// Provides data about how the editor instance was transformed. + pub history: RevisionHistory, + /// A reference to the metadata that the editor was created for. + pub meta: &'meta mut M, +} + +impl<'meta, M: RefMetadata> MaterializeOutcome<'meta, M> { + /// The mutated commit graph the rebase materialized โ€” the next workspace state. + /// Feed it to `Workspace::refresh_from_commit_graph` to bring a workspace up to date. + pub fn arena(&self) -> &but_graph::CommitGraph { + self.graph.arena() + } +} + +/// Provides lookup for different steps that a selector might point to. +pub trait LookupStep { + /// Look up the step that a given selector corresponds to. + fn lookup_step(&self, selector: Selector) -> Result; + + /// Look up the step for a given selector and assert it's a pick. + fn lookup_pick(&self, selector: Selector) -> Result { + match self.lookup_step(selector)? { + Step::Pick(Pick { id, .. }) => Ok(id), + _ => bail!("Expected selector to point to a pick"), + } + } + + /// Look up the step for a given selector and assert it's a reference. + fn lookup_reference(&self, selector: Selector) -> Result { + match self.lookup_step(selector)? { + Step::Reference { refname, .. } => Ok(refname), + _ => bail!("Expected selector to point to a reference"), + } + } +} + +impl LookupStep for Editor<'_, M> { + fn lookup_step(&self, selector: Selector) -> Result { + Ok(self.graph.step_view(selector.id)) + } +} + +impl LookupStep for SuccessfulRebase<'_, M> { + fn lookup_step(&self, selector: Selector) -> Result { + Ok(self.graph.step_view(selector.id)) + } +} + +impl LookupStep for MaterializeOutcome<'_, M> { + fn lookup_step(&self, selector: Selector) -> Result { + Ok(self.graph.step_view(selector.id)) + } +} + /// Represents a commit to be cherry-picked in a rebase operation. #[derive(Debug, Clone, PartialEq)] pub struct Pick { @@ -53,7 +285,7 @@ pub struct Pick { pub sign_commit: SignCommit, /// Exclude the commit from being included in the /// [`RevisionHistory::commit_mappings()`]. This is helpful if we are - /// creating a new commit since the the mappings will be non-sensical to the + /// creating a new commit since the mappings will be non-sensical to the /// frontend consumers. pub exclude_from_tracking: bool, /// If set to false, the rebase will fail if this commit results in a @@ -117,7 +349,7 @@ impl Pick { pub enum Step { /// Cherry picks the given commit into the new location in the graph Pick(Pick), - /// Represents applying a reference to the commit found at it's first parent + /// A reference command, routed into the ref table as positioned data โ€” never a node. Reference { /// The refname refname: gix::refs::FullName, @@ -128,7 +360,12 @@ pub enum Step { /// kept in the graph for traversal but never written. mutable: bool, }, - /// Used as a placeholder after removing a pick or reference + /// A tombstone left behind when a pick or reference is removed. + /// + /// The entry is never deleted from the arena โ€” that would invalidate every entry id after it. + /// Instead the parent number becomes `None`, keeping ids stable. It retains its first + /// outgoing edge so that resolving a reference downward walks THROUGH it to the next live + /// pick. A tombstone must not survive into materialized output. None, } @@ -149,7 +386,7 @@ impl Step { /// Creates a mutable reference step. /// /// References constructed by edit operations are mutable; immutable - /// references only originate from non-`HEAD`-reachable segments during + /// references only originate from non-`HEAD`-reachable territory during /// [`Editor::create`]. pub fn new_reference(refname: gix::refs::FullName) -> Self { Self::Reference { @@ -159,25 +396,13 @@ impl Step { } } -/// Used to represent a connection between a given commit. -#[derive(Debug, Clone)] -pub(crate) struct Edge { - /// Represents in which order the `parent` fields should be written out - /// - /// A child commit should have edges that all have unique orders. In order - /// to achive that we can employ the following semantics. - order: usize, -} - -type StepGraphIndex = petgraph::stable_graph::NodeIndex; -type StepGraph = petgraph::stable_graph::StableDiGraph; +pub(crate) use graph_editor::{EditorIndex, GraphEditor, PickIndex}; /// Convert a structure to a selector for a particular editor. /// -/// `ToSelector` does _not_ normalize a selector. pub trait ToSelector { /// Converts a given object into a selector. Calling `to_selector` on an - /// object asserts that the reciever was a object that is selectable in the + /// object asserts that the receiver was a object that is selectable in the /// graph. fn to_selector(&self, editor: &Editor) -> Result; } @@ -185,7 +410,7 @@ pub trait ToSelector { /// Convert a type to a selector, and ensures that it is type commit. pub trait ToCommitSelector { /// Converts a given object into a selector. Calling `to_commit_selector` on - /// an object asserts that the reciever has a selectable pick step in the + /// an object asserts that the receiver has a selectable pick step in the /// graph. fn to_commit_selector(&self, editor: &Editor) -> Result; } @@ -193,46 +418,41 @@ pub trait ToCommitSelector { /// Convert a type to a selector, and ensures that it is type reference. pub trait ToReferenceSelector { /// Converts a given object into a selector. Calling `to_reference_selector` on - /// an object asserts that the reciever has a selectable reference step in + /// an object asserts that the receiver has a selectable reference step in /// the graph. fn to_reference_selector(&self, editor: &Editor) -> Result; } /// Points to a step in the rebase editor. /// -/// Hash, PartialEq, and Eq are implemented for this struct. Because selectors -/// are a pointer to a node in a particular version of the Editor's internal -/// representation, it means that you can have two selectors that when -/// normalised point to the same node. If you want to ensure you have just one -/// selector to a given node, make sure you are working with selectors all -/// normalised to the latest revision of the Editor. +/// Step indices are stable across mutation and rebase โ€” a selector taken at +/// any point remains valid for the lifetime of the editor. Deleted steps +/// become tombstones ([`Step::None`]) rather than being removed, so a selector +/// never dangles, though it may point at a tombstone. #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] pub struct Selector { - id: StepGraphIndex, - revision: usize, + id: EditorIndex, } impl ToCommitSelector for Selector { fn to_commit_selector(&self, editor: &Editor) -> Result { - let selector = editor.history.normalize_selector(*self)?; - let step = &editor.graph[selector.id]; + let step = editor.graph.step_view(self.id); if !matches!(step, Step::Pick(_)) { bail!("Expected selector for {step:?} to refer to a commit"); } - Ok(selector) + Ok(*self) } } impl ToReferenceSelector for Selector { fn to_reference_selector(&self, editor: &Editor) -> Result { - let selector = editor.history.normalize_selector(*self)?; - let step = &editor.graph[selector.id]; - if !matches!(step, Step::Reference { .. }) { + if !editor.graph.is_reference(self.id) { + let step = editor.graph.step_view(self.id); bail!("Expected selector for {step:?} to refer to a reference"); } - Ok(selector) + Ok(*self) } } @@ -256,259 +476,24 @@ pub(crate) enum Checkout { }, } -/// Used to manipulate a set of picks. -#[derive(Debug)] -pub struct Editor<'ws, 'meta, M: RefMetadata> { - /// The internal graph of steps - graph: StepGraph, - /// Initial references. This is used to track any references that might need - /// deleted. - initial_references: Vec, - /// Worktrees that we might need to perform `safe_checkout` on. - checkouts: Vec, - /// The in-memory repository that the rebase engine works with. - repo: gix::Repository, - /// Provides data about how the editor instance was transformed. - history: RevisionHistory, - /// A reference to the workspace that the editor was created for. - workspace: &'ws mut but_graph::Workspace, - /// A reference to the metadata that the editor was created for. - meta: &'meta mut M, -} - -/// Represents a successful rebase, and any valid, but potentially conflicting scenarios it had. -#[derive(Debug)] -pub struct SuccessfulRebase<'ws, 'meta, M: RefMetadata> { - pub(crate) repo: gix::Repository, - pub(crate) initial_references: Vec, - /// Any reference edits that need to be committed as a result of the history - /// rewrite - pub(crate) ref_edits: Vec, - /// The new step graph - pub(crate) graph: StepGraph, - pub(crate) checkouts: Vec, - /// Provides data about how the editor instance was transformed. - pub history: RevisionHistory, - /// A reference to the workspace that the editor was created for. - workspace: &'ws mut but_graph::Workspace, - /// A reference to the metadata that the editor was created for. - meta: &'meta mut M, -} - -impl<'ws, 'meta, M: RefMetadata> SuccessfulRebase<'ws, 'meta, M> { - /// Returns the in-memory repository that backs this rebase preview. - /// - /// This repository may contain objects that have not been persisted yet, - /// which makes it suitable for dry-run inspection of [`Self::overlayed_graph`]. - pub fn repo(&self) -> &gix::Repository { - &self.repo - } - - /// Returns the preview repository together with mutable access to the - /// ref-metadata the editor was created with. - /// - /// Use this to build post-rebase projections that need both, like a - /// workspace preview computed from [`Self::overlayed_graph`]. - pub fn repo_and_meta_mut(&mut self) -> (&gix::Repository, &mut M) { - (&self.repo, self.meta) - } - - /// Return the commit targeted by `ref_name` in the post-rebase step graph. - pub fn reference_target(&self, ref_name: &gix::refs::FullNameRef) -> Result { - let reference = self - .graph - .node_indices() - .find(|node| { - matches!( - &self.graph[*node], - Step::Reference { refname, .. } if refname.as_ref() == ref_name - ) - }) - .with_context(|| format!("Could not find reference '{ref_name}' in rebase result"))?; - let parent = collect_ordered_parents(&self.graph, reference) - .into_iter() - .next() - .context("Reference has no target commit in rebase result")?; - match self.graph[parent] { - Step::Pick(Pick { id, .. }) => Ok(id), - _ => bail!("Reference target in rebase result is not a commit"), - } - } - - /// Returns a preview of what the but-graph will look like after - /// materialization. - /// - /// Any objects referenced in the resulting graph must be accessed via the - /// in-memory repository owned by this [`SuccessfulRebase`] (`self.repo`), - /// since they might exist only in memory. - pub fn overlayed_graph(&self) -> Result { - self.overlayed_graph_with_workspace_overrides(None, None) - } - - /// Return the post-rebase graph with optional ad-hoc workspace projection overrides. - /// - /// This is useful for dry-run operations whose graph rewrite is accompanied by metadata or - /// checkout changes that are deliberately not persisted. The override entrypoint must name the - /// commit and local reference that would be checked out, while `branch_stack_order` supplies the - /// tip-to-base order that would be written to ref metadata. - pub fn overlayed_graph_with_workspace_overrides( - &self, - entrypoint: Option<(gix::ObjectId, gix::refs::FullName)>, - branch_stack_order: Option<&[gix::refs::FullName]>, - ) -> Result { - let dropped_refs = self.ref_edits.iter().filter_map(|edit| match &edit.change { - gix::refs::transaction::Change::Delete { .. } => Some(edit.name.clone()), - _ => None, - }); - let updated_refs = self.ref_edits.iter().filter_map(|edit| match &edit.change { - gix::refs::transaction::Change::Update { new, .. } => Some(gix::refs::Reference { - name: edit.name.clone(), - target: new.clone(), - // TODO(CTO): Peeled is only relevant for symbolic refs? - peeled: None, - }), - _ => None, - }); - - let Some((entrypoint_id, entrypoint_refname)) = self - .checkouts - .iter() - .filter_map(|checkout| match checkout { - Checkout::Head { selector, .. } => { - let selector = self.history.normalize_selector(*selector).ok()?; - let step = &self.graph[selector.id]; - - match step { - Step::None => None, - Step::Pick(Pick { id, .. }) => Some((*id, None)), - Step::Reference { refname, .. } => { - let parents = collect_ordered_parents(&self.graph, selector.id); - - if let Some(to_reference) = parents.first() - && let Step::Pick(Pick { id, .. }) = self.graph[*to_reference] - { - Some((id, Some(refname.clone()))) - } else { - None - } - } - } - } - }) - .next() - else { - bail!("BUG: Tried to construct rebase engine graph overlay with no entrypoints"); - }; - - let (entrypoint_id, entrypoint_refname) = entrypoint - .map_or((entrypoint_id, entrypoint_refname), |(id, ref_name)| { - (id, Some(ref_name)) - }); - let mut overlay = Overlay::default() - .with_references(updated_refs) - .with_dropped_references(dropped_refs) - .with_entrypoint(entrypoint_id, entrypoint_refname); - if let Some(branch_stack_order) = branch_stack_order { - overlay = overlay.with_branch_stack_order_override(branch_stack_order.iter().cloned()); - } - self.workspace - .graph - .redo_traversal_with_overlay(&self.repo, self.meta, overlay) - } -} - -/// The outcome of a materialize -#[derive(Debug)] -pub struct MaterializeOutcome<'ws, 'meta, M: RefMetadata> { - pub(crate) graph: StepGraph, - /// Provides data about how the editor instance was transformed. - pub history: RevisionHistory, - /// A reference to the workspace that the editor was created for. - pub workspace: &'ws mut but_graph::Workspace, - /// A reference to the metadata that the editor was created for. - pub meta: &'meta mut M, -} - -/// Provides lookup for different steps that a selector might point to. -pub trait LookupStep { - /// Look up the step that a given selector corresponds to. - fn lookup_step(&self, selector: Selector) -> Result; - - /// Look up the step a given selector and assert it's a pick. - fn lookup_pick(&self, selector: Selector) -> Result { - match self.lookup_step(selector)? { - Step::Pick(Pick { id, .. }) => Ok(id), - _ => bail!("Expected selector to point to a pick"), - } - } - - /// Look up the step a given selector and assert it's a pick. - fn lookup_reference(&self, selector: Selector) -> Result { - match self.lookup_step(selector)? { - Step::Reference { refname, .. } => Ok(refname), - _ => bail!("Expected selector to point to a reference"), - } - } -} - -impl LookupStep for Editor<'_, '_, M> { - fn lookup_step(&self, selector: Selector) -> Result { - lookup_step(&self.graph, &self.history, selector) - } -} - -impl LookupStep for SuccessfulRebase<'_, '_, M> { - fn lookup_step(&self, selector: Selector) -> Result { - lookup_step(&self.graph, &self.history, selector) - } -} - -impl LookupStep for MaterializeOutcome<'_, '_, M> { - fn lookup_step(&self, selector: Selector) -> Result { - lookup_step(&self.graph, &self.history, selector) - } -} - -fn lookup_step(graph: &StepGraph, history: &RevisionHistory, selector: Selector) -> Result { - let normalized = history.normalize_selector(selector)?; - Ok(graph[normalized.id].clone()) -} - -/// Provides data about how the editor instance was transformed. +/// How commit ids moved as the editor transformed the graph. #[derive(Debug, Clone, Default)] pub struct RevisionHistory { - mappings: Vec>, /// A mapping from any commits that were in the original mapping to a /// rewritten version. /// - /// Unintuatively, the values are the original values, and the keys are the + /// Unintuitively, the values are the original values, and the keys are the /// _new_ values that they have been mapped to. commit_mappings: BTreeMap, } -impl<'ws, 'meta, M: RefMetadata> Editor<'ws, 'meta, M> { - pub(crate) fn new_selector(&self, id: StepGraphIndex) -> Selector { - Selector { - id, - revision: self.history.current_revision(), - } - } -} - impl RevisionHistory { pub(crate) fn new() -> Self { Default::default() } - pub(crate) fn current_revision(&self) -> usize { - self.mappings.len() - } - - /// The commit mappings starts empty, and gets updated when we perform a cherry pick. - /// If there is no entry whose old `to` that cooresponds with the new - /// `from`, then we just add a `to <- from` entry. - /// If there is an entry whose old `to` that cooresponds with the new - /// `from`, then we replace `old_to <- old_from` with `new_to <- old_from` + /// Record that `from` was rewritten to `to`. If `from` was itself the result of an + /// earlier rewrite, the chain collapses: the original id now maps straight to `to`. pub(crate) fn update_mapping(&mut self, from: gix::ObjectId, to: gix::ObjectId) { if let Some(value) = self.commit_mappings.remove(&from) { self.commit_mappings.insert(to, value); @@ -524,20 +509,6 @@ impl RevisionHistory { .filter_map(|(k, v)| if k == v { None } else { Some((*v, *k)) }) .collect() } - - pub(crate) fn normalize_selector(&self, mut selector: Selector) -> Result { - while selector.revision < self.current_revision() { - selector.id = *self.mappings[selector.revision] - .get(&selector.id) - .context("Failed to normalize selector, selector was missing from the mapping")?; - selector.revision += 1; - } - Ok(selector) - } - - pub(crate) fn add_revision(&mut self, mapping: HashMap) { - self.mappings.push(mapping); - } } /// I wanted to assert _somewhere_ the defaults for non-workspace & workspace commits. It doesn't feel like the right place to do it in integration tests because we should assert behaviour rather than details there. diff --git a/crates/but-rebase/src/graph_rebase/mutate.rs b/crates/but-rebase/src/graph_rebase/mutate.rs index d88e7448729..e6e3abd73e1 100644 --- a/crates/but-rebase/src/graph_rebase/mutate.rs +++ b/crates/but-rebase/src/graph_rebase/mutate.rs @@ -1,239 +1,149 @@ //! Operations for mutating the editor -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; +use crate::graph_rebase::graph_editor::{PickIndex, RefIndex}; +use crate::graph_rebase::ref_ops::{ + SplitBoundary, StackPlace, carry_stack_above, land_stack_above, move_ref, place_ref, + readopt_dangling_refs, redirect_edges, repoint_ref, settle_group_lower, split_group, + transfer_stack, unhook_ref, +}; +use crate::graph_rebase::{EditorIndex, GraphEditor, positions}; use anyhow::{Context as _, Result, anyhow, bail}; use but_core::RefMetadata; -use petgraph::{Direction, visit::EdgeRef}; use serde::{Deserialize, Serialize}; -use crate::graph_rebase::{ - Edge, Editor, Pick, Selector, Step, ToCommitSelector, ToReferenceSelector, ToSelector, -}; - -/// Describes where relative to the selector a step should be inserted -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))] -pub enum InsertSide { - /// When inserting above, any nodes that point to the selector will now - /// point to the inserted node instead. - /// - /// IE: Any child commits will become a child of what is getting inserted. - Above, - /// When inserting below, any nodes that the selector points to will now be - /// pointed to by the inserted node instead. - /// - /// IE: Any parent commits will become a parent of what is getting inserted. - Below, -} -#[cfg(feature = "export-schema")] -but_schemars::register_sdk_type!(InsertSide); - -/// Controls where reparented insertion-location parents are ordered relative to -/// existing parents on the segment. -#[derive(Debug, Clone)] -pub enum ParentReparentingOrder { - /// Put reparented insertion-location parents before existing segment parents. - Prepend, - /// Put reparented insertion-location parents after existing segment parents. - Append, -} - -/// Defines the start and end of a segment by pointing to it's parent-most and child-most nodes. -#[derive(Debug, Clone)] -pub struct SegmentDelimiter -where - C: ToSelector, - P: ToSelector, -{ - /// The child-most node contained within the segment being defined. - pub child: C, - /// The parent-most node contained within the segment being defined. - pub parent: P, -} - -/// A set of some selectors -#[derive(Debug, Clone)] -pub struct SomeSelectors { - selectors: Vec, -} - -impl SomeSelectors { - /// Creates a set of selectors from different selector input types. - /// - /// Errors out if the selectors iterator is empty. - pub fn new(selectors: impl IntoIterator) -> Result - where - T: Into, - { - let selectors: Vec = selectors.into_iter().map(Into::into).collect(); - - if selectors.is_empty() { - return Err(anyhow!("Invalid selector set: This cannot be empty")); - } - - Ok(Self { selectors }) - } - - /// Returns selectors as a slice. - pub fn as_slice(&self) -> &[AnySelector] { - &self.selectors - } -} - -/// A heterogeneous selector input. -#[derive(Debug, Clone)] -pub enum AnySelector { - /// A selector that already points into the current graph revision. - Selector(Selector), - /// A commit id that should resolve to a pick step. - Commit(gix::ObjectId), - /// A reference name that should resolve to a reference step. - Reference(gix::refs::FullName), -} - -impl ToSelector for AnySelector { - fn to_selector(&self, editor: &Editor) -> Result { - match self { - Self::Selector(selector) => selector.to_selector(editor), - Self::Commit(id) => editor.select_commit(*id), - Self::Reference(reference) => editor.select_reference(reference.as_ref()), - } - } -} +use crate::graph_rebase::selector::{SelectorSet, SomeSelectors, StepRange}; +use crate::graph_rebase::{Editor, Selector, Step, ToSelector}; -impl From for AnySelector { - fn from(value: Selector) -> Self { - Self::Selector(value) - } +/// Edge positions captured at one instant (a frame), looked up later against parent arrays +/// that have since shifted. `current` maps a captured position to today's parent number; +/// removals and inserts are noted so later lookups stay aligned. `None` means the named +/// edge itself was already removed. +#[derive(Default)] +struct NumberLedger { + map: HashMap>>, } -impl From for AnySelector { - fn from(value: gix::ObjectId) -> Self { - Self::Commit(value) - } -} - -impl From for AnySelector { - fn from(value: gix::refs::FullName) -> Self { - Self::Reference(value) - } -} - -impl TryFrom> for SomeSelectors -where - T: Into, -{ - type Error = anyhow::Error; - - fn try_from(value: Vec) -> std::result::Result { - Self::new(value) +impl NumberLedger { + /// Today's parent number of the edge captured as `(source, frame_number)`, or `None` if it was + /// removed. The identity map is built lazily, so a source must be looked up before any + /// of its parent numbers mutate. + fn current( + &mut self, + graph: &GraphEditor, + source: PickIndex, + frame_number: usize, + ) -> Option { + self.map + .entry(source) + .or_insert_with(|| (0..graph.parent_count(source)).map(Some).collect()) + .get(frame_number) + .copied() + .flatten() } -} - -/// Defines a set of node children or parents, to perform an action on. -/// -/// Currently, this is used in the disconnect functionality. -#[derive(Debug, Clone, Default)] -pub enum SelectorSet { - /// Select all of the children or parents. - #[default] - All, - /// No children or parents should be selected. - None, - /// A subset of children or parents should be selected. - Some(SomeSelectors), -} -/// An enum that is helpful for describing where something should be inserted -/// relative to. -#[derive(Debug, Clone)] -pub enum RelativeToRef<'a> { - /// Relative to a commit - Commit(gix::ObjectId), - /// Relative to a reference - Reference(&'a gix::refs::FullNameRef), -} - -impl ToSelector for RelativeToRef<'_> { - fn to_selector(&self, editor: &Editor) -> Result { - match self { - Self::Commit(id) => editor.select_commit(*id), - Self::Reference(reference) => editor.select_reference(reference), + fn note_remove(&mut self, source: PickIndex, removed: usize) { + let entries = self.map.get_mut(&source).expect("looked up before noting"); + for entry in entries.iter_mut() { + match entry { + Some(parent_number) if *parent_number == removed => *entry = None, + Some(parent_number) if *parent_number > removed => *parent_number -= 1, + _ => {} + } } } -} -/// Specifies a location relative to which a commit operation should occur. -/// This is the fully-owned cousin of [RelativeTo]. -#[derive(Debug, Clone)] -pub enum RelativeTo { - /// Relative to a commit. - Commit(gix::ObjectId), - /// Relative to a reference. - Reference(gix::refs::FullName), -} - -impl ToSelector for RelativeTo { - fn to_selector(&self, editor: &Editor) -> Result { - match self { - Self::Commit(commit) => editor.select_commit(*commit), - Self::Reference(reference) => editor.select_reference(reference.as_ref()), + fn note_insert(&mut self, source: PickIndex, inserted: usize) { + let entries = self.map.get_mut(&source).expect("looked up before noting"); + for parent_number in entries.iter_mut().flatten() { + if *parent_number >= inserted { + *parent_number += 1; + } } } } -impl ToCommitSelector for gix::ObjectId { - fn to_commit_selector(&self, editor: &Editor) -> Result { - editor.select_commit(*self) - } +/// The ref-table handle of `entry`; an error when it addresses a commit instead. +fn ref_entry(entry: EditorIndex) -> Result { + entry + .as_ref() + .context("operation targets a reference, but a commit was addressed") } -impl ToCommitSelector for gix::Id<'_> { - fn to_commit_selector(&self, editor: &Editor) -> Result { - editor.select_commit(self.detach()) - } +/// The node-arena handle of `entry`; an error when it addresses a reference instead. +pub(crate) fn node_entry(entry: EditorIndex) -> Result { + entry + .as_pick() + .context("operation targets a commit, but a reference was addressed") } -impl ToSelector for gix::ObjectId { - fn to_selector(&self, editor: &Editor) -> Result { - editor.select_commit(*self) - } -} - -impl ToSelector for gix::Id<'_> { - fn to_selector(&self, editor: &Editor) -> Result { - editor.select_commit(self.detach()) - } -} - -impl ToReferenceSelector for &gix::refs::FullNameRef { - fn to_reference_selector(&self, editor: &Editor) -> Result { - editor.select_reference(self) +/// Phase 1 of [`Editor::disconnect_range_from`]: verify that all parents and children to +/// disconnect (`None` = all) are directly connected to the target range. +fn verify_disconnect_bounds( + parents_to_disconnect: Option<&[Selector]>, + children_to_disconnect: Option<&[Selector]>, + available_parents: &HashSet, + available_children: &HashSet, +) -> Result<()> { + fn verify( + to_disconnect: Option<&[Selector]>, + available: &HashSet, + what: &str, + ) -> Result<()> { + for selector in to_disconnect.into_iter().flatten() { + if !selector + .id + .as_pick() + .is_some_and(|n| available.contains(&n)) + { + return Err(anyhow!( + "Invalid {what} delimitation: requested {what} is not a direct {what} of target.{what}" + )); + } + } + Ok(()) } + verify(parents_to_disconnect, available_parents, "parent")?; + verify(children_to_disconnect, available_children, "child") } -impl ToReferenceSelector for gix::refs::FullName { - fn to_reference_selector(&self, editor: &Editor) -> Result { - editor.select_reference(self.as_ref()) +/// Route a step command to its namespace: references into the ref table, everything else +/// into the node arena. +fn add_step_to_graph(graph: &mut GraphEditor, step: Step) -> EditorIndex { + match step { + Step::Reference { refname, mutable } => graph.add_reference(refname, mutable).into(), + Step::Pick(pick) => graph.add_node(Some(pick)).into(), + Step::None => graph.add_node(None).into(), } } -impl ToSelector for &gix::refs::FullNameRef { - fn to_selector(&self, editor: &Editor) -> Result { - editor.select_reference(self) - } +/// Describes where relative to the selector a step should be inserted +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))] +pub enum InsertSide { + /// Children of the selector become children of the inserted entry. + Above, + /// Parents of the selector become parents of the inserted entry. + Below, } +#[cfg(feature = "export-schema")] +but_schemars::register_sdk_type!(InsertSide); -impl ToSelector for gix::refs::FullName { - fn to_selector(&self, editor: &Editor) -> Result { - editor.select_reference(self.as_ref()) - } +/// Controls where reparented insertion-location parents are ordered relative to +/// existing parents on the range. +#[derive(Debug, Clone)] +pub enum ParentReparentingOrder { + /// Reparented parents come first: the first of them becomes the first-parent + /// traversal path; existing parents stay attached after them as merge-side parents. + Prepend, + /// Existing parents keep the lowest parent orders; reparented parents append after them. + Append, } -/// Operations for mutating the commit graph -impl Editor<'_, '_, M> { +/// Operations for mutating the editor graph +impl Editor<'_, M> { /// Get a selector to a particular commit in the graph pub fn select_commit(&self, target: gix::ObjectId) -> Result { match self.try_select_commit(target) { @@ -254,10 +164,8 @@ impl Editor<'_, '_, M> { /// Get a selector to a particular commit in the graph pub fn try_select_commit(&self, target: gix::ObjectId) -> Option { - for node_idx in self.graph.node_indices() { - if let Step::Pick(Pick { id, .. }) = self.graph[node_idx] - && id == target - { + for node_idx in self.graph.node_ids() { + if self.graph.commit_id(node_idx) == Some(target) { return Some(self.new_selector(node_idx)); } } @@ -267,113 +175,66 @@ impl Editor<'_, '_, M> { /// Get a selector to a particular reference in the graph pub fn try_select_reference(&self, target: &gix::refs::FullNameRef) -> Option { - for node_idx in self.graph.node_indices() { - if let Step::Reference { refname, .. } = &self.graph[node_idx] - && target == refname.as_ref() - { - return Some(self.new_selector(node_idx)); + for (ref_idx, refname, _) in self.graph.references() { + if target == refname.as_ref() { + return Some(self.new_selector(ref_idx)); } } None } - /// Returns all direct children of `target` together with their edge order. - /// - /// Children are represented as incoming edges into `target` in the step graph. - pub fn direct_children(&self, target: impl ToSelector) -> Result> { - let target = self.history.normalize_selector(target.to_selector(self)?)?; - Ok(self - .graph - .edges_directed(target.id, Direction::Incoming) - .map(|edge| (self.new_selector(edge.source()), edge.weight().order)) - .collect()) - } - - /// Returns all direct parents of `target` together with their edge order. - /// - /// Parents are represented as outgoing edges from `target` in the step graph. - pub fn direct_parents(&self, target: impl ToSelector) -> Result> { - let target = self.history.normalize_selector(target.to_selector(self)?)?; - Ok(self - .graph - .edges_directed(target.id, Direction::Outgoing) - .map(|edge| (self.new_selector(edge.target()), edge.weight().order)) - .collect()) - } - - /// For a given step, find all the references that point to it. - /// - /// The reference selectors are provided in no particular order. - pub fn step_references(&self, target: impl ToSelector) -> Result> { - let target = self.history.normalize_selector(target.to_selector(self)?)?; - - let mut references = vec![]; - let mut seen = HashSet::new(); - let mut tips = vec![target.id]; - - while let Some(tip) = tips.pop() { - for edge in self.graph.edges_directed(tip, Direction::Incoming) { - let child = edge.source(); - if !seen.insert(child) { - continue; - } - - match &self.graph[child] { - Step::None => tips.push(child), - Step::Reference { .. } => { - references.push(self.new_selector(child)); - tips.push(child); - } - _ => {} - } - } - } - - Ok(references) - } - - /// Replaces the node that the function was pointing to. - /// - /// If a commit step has been replaced with another commit step, the commit - /// mappings will get updated to include an entry going from the old to the - /// new object id. - /// - /// Returns the replaced step. - pub fn replace(&mut self, target: impl ToSelector, mut step: Step) -> Result { - let target = self.history.normalize_selector(target.to_selector(self)?)?; - if let (Step::Pick(from), Step::Pick(to)) = (&self.graph[target.id], &step) + /// Replace the step at `target`, returning the old one. Replacing a pick with another + /// pick records a commit mapping from the old to the new id. Replacement stays within + /// its namespace: a pick can become a pick or a tombstone, a reference can be renamed + /// or deleted โ€” never one into the other. + pub fn replace(&mut self, target: impl ToSelector, step: Step) -> Result { + let target = target.to_selector(self)?; + let old = self.graph.step_view(target.id); + if let (Step::Pick(from), Step::Pick(to)) = (&old, &step) && !from.exclude_from_tracking && !to.exclude_from_tracking { self.history.update_mapping(from.id, to.id); - }; - std::mem::swap(&mut self.graph[target.id], &mut step); - Ok(step) + } + let is_reference_target = self.graph.state_of(target.id).is_some(); + if is_reference_target { + self.ensure_mutable_ref(target.id)?; + } + match (is_reference_target, step) { + (false, Step::Pick(pick)) => self.graph.set_step(node_entry(target.id)?, Some(pick)), + (false, Step::None) => self.graph.set_step(node_entry(target.id)?, None), + (true, Step::Reference { refname, mutable }) => { + self.graph + .set_reference(ref_entry(target.id)?, refname, mutable) + } + // Deleting a reference removes it from the physical stack: splice dependents + // past it. Name and stored position are kept for retention reads. + (true, Step::None) => { + let entry = ref_entry(target.id)?; + let was_live = self.graph.is_reference(entry); + self.graph.tombstone_reference(entry); + if was_live { + self.graph.splice(entry); + } + } + (false, Step::Reference { .. }) => { + bail!("cannot replace a commit step with a reference") + } + (true, Step::Pick(_)) => bail!("cannot replace a reference with a commit step"), + } + Ok(old) } - /// Disconnect a segment from a parent segment. - /// - /// `target` - The segment to disconnect. - /// `children_to_disconnect` - Child nodes to disconnect from `target.child`. - /// If `SelectorSet::All`, all incoming children of `target.child` are disconnected. - /// - /// `parents_to_disconnect` - Parent nodes to disconnect from `target.parent`. - /// If `SelectorSet::All`, all outgoing parents of `target.parent` are disconnected. - /// - /// `target` delimiter's child and parent can be the same node. - /// This is the way to disconnect a single node. - /// - /// All disconnected children will be reconnected to all the disconnected parents unless - /// the `skip_reconnect_step` is set to true. - /// - /// Returns an error when: - /// - `parents_to_disconnect` is `SelectorSet::None` and `skip_reconnect_step` is false. - /// - `parents_to_disconnect` contains any parent that is not a direct parent of `target.parent`. - /// - `children_to_disconnect` contains any child that is not a direct parent of `target.child`. - pub fn disconnect_segment_from( + /// Disconnect a range (child and parent may be the same entry) from its surroundings: + /// children from `target.child`, parents from `target.parent`. `SelectorSet::All` + /// disconnects everything; a `Some` set must name direct children/parents or this + /// errors. Disconnected children are reconnected to the disconnected parents unless + /// `skip_reconnect_step` is set โ€” which is also the only way `parents_to_disconnect` + /// may be `SelectorSet::None`. + pub fn disconnect_range_from( &mut self, - target: SegmentDelimiter, + target: StepRange, children_to_disconnect: SelectorSet, parents_to_disconnect: SelectorSet, skip_reconnect_step: bool, @@ -382,9 +243,42 @@ impl Editor<'_, '_, M> { C: ToSelector, P: ToSelector, { - let SegmentDelimiter { child, parent } = target; - let target_child = self.history.normalize_selector(child.to_selector(self)?)?; - let target_parent = self.history.normalize_selector(parent.to_selector(self)?)?; + let StepRange { child, parent } = target; + let mut target_child = child.to_selector(self)?; + let mut target_parent = parent.to_selector(self)?; + // A single-entry range that is just a reference: it leaves its group (members above + // close the gap) and gives up its edges โ€” with a reconnect they stay as plain edges + // onto the pick, without one they are removed outright. + if target_child.id == target_parent.id && self.graph.is_positioned(target_child.id) { + self.ensure_mutable_ref(target_child.id)?; + unhook_ref( + &mut self.graph, + ref_entry(target_child.id)?, + skip_reconnect_step, + ); + return Ok(()); + } + // A reference range stands for the pick it resolves to: edges are the truth for + // picks, and the reference's group rides the pick's links as position data. A + // reference child only owns the edges entering its own group โ€” plain edges into + // its pick belong to it and stay. + let child_ref_positioned = self.graph.is_positioned(target_child.id); + let child_ref_edges = + child_ref_positioned.then(|| positions::edges_through(&self.graph, target_child.id)); + let child_ref_depth = child_ref_positioned + .then_some(()) + .as_ref() + .map(|_| positions::ref_depth(&self.graph, target_child.id)); + if let Some(pick) = + crate::graph_rebase::positions::resolve_to_pick(&self.graph, target_child.id) + { + target_child = self.new_selector(pick); + } + if let Some(pick) = + crate::graph_rebase::positions::resolve_to_pick(&self.graph, target_parent.id) + { + target_parent = self.new_selector(pick); + } let children_to_disconnect = match children_to_disconnect { SelectorSet::All => None, SelectorSet::None => Some(Vec::new()), @@ -393,9 +287,6 @@ impl Editor<'_, '_, M> { .as_slice() .iter() .map(|from_child| from_child.to_selector(self)) - .collect::>>()? - .into_iter() - .map(|selector| self.history.normalize_selector(selector)) .collect::>>()?, ), }; @@ -416,195 +307,366 @@ impl Editor<'_, '_, M> { .as_slice() .iter() .map(|from_parent| from_parent.to_selector(self)) - .collect::>>()? - .into_iter() - .map(|selector| self.history.normalize_selector(selector)) .collect::>>()?, ), }; - // Edges to children. + // The range bounds as PICKS: a reference bound stands for the pick it resolves to. + let parent_node = self.resolved_pick(target_parent.id)?; + let child_node = self.resolved_pick(target_child.id)?; + // Edges from children, as frame-coordinate (child, parent number) names. let incoming_edges = self .graph - .edges_directed(target_child.id, Direction::Incoming) - .map(|e| (e.id(), e.weight().to_owned(), e.source())) + .incoming_edges(target_child.id) + .iter() + .copied() + .filter(|edge| { + child_ref_edges + .as_ref() + .is_none_or(|entering| entering.contains(edge)) + }) .collect::>(); - // Edges to parents. + // Edges to parents, as frame-coordinate (parent number, parent) entries. let outgoing_edges = self .graph - .edges_directed(target_parent.id, Direction::Outgoing) - .map(|e| (e.id(), e.weight().to_owned(), e.target())) + .parents(parent_node) + .iter() + .copied() + .enumerate() .collect::>(); // All available parents let available_parents = outgoing_edges .iter() - .map(|(_, _, edge_target)| *edge_target) + .map(|(_, edge_target)| *edge_target) .collect::>(); let available_children = incoming_edges .iter() - .map(|(_, _, edge_source)| *edge_source) + .map(|(edge_source, _)| *edge_source) .collect::>(); - // 1. Verify that all parents and children to disconnect are directly connected to the target segment. - if let Some(parents_to_disconnect) = parents_to_disconnect.as_ref() { - for selector in parents_to_disconnect { - if !available_parents.contains(&selector.id) { - return Err(anyhow!( - "Invalid parent delimitation: requested parent is not a direct parent of target.parent" - )); + // Requested selectors that are references stand for the links their positions + // decorate: a parent reference maps to its resolved pick; a child reference maps to the + // pick(s) entering its group. + let parents_to_disconnect = parents_to_disconnect.map(|parents| { + parents + .into_iter() + .map(|selector| { + match crate::graph_rebase::positions::resolve_to_pick(&self.graph, selector.id) + { + Some(pick) if EditorIndex::from(pick) != selector.id => { + self.new_selector(pick) + } + _ => selector, + } + }) + .collect::>() + }); + // A requested child that is a reference is a group member above the range: its + // edges are the edges to disconnect, and the member itself (with everything above it + // in its group) follows the disconnected parents. + let mut moving_ref_children: Vec = Vec::new(); + let children_to_disconnect = children_to_disconnect.map(|children| { + children + .into_iter() + .flat_map(|selector| { + if self.graph.is_positioned(selector.id) { + let edges = positions::edges_through(&self.graph, selector.id) + .into_iter() + .map(|(child, _)| self.new_selector(child)) + .collect::>(); + moving_ref_children.extend(selector.id.as_ref()); + edges + } else { + vec![selector] + } + }) + .collect::>() + }); + + // 1. Verify the requested bounds, then resolve them to node-id sets (`None` = all). + verify_disconnect_bounds( + parents_to_disconnect.as_deref(), + children_to_disconnect.as_deref(), + &available_parents, + &available_children, + )?; + let parent_ids_to_disconnect = parents_to_disconnect.as_ref().map(|parents| { + parents + .iter() + .filter_map(|s| s.id.as_pick()) + .collect::>() + }); + let child_ids_to_disconnect = children_to_disconnect.as_ref().map(|children| { + children + .iter() + .filter_map(|s| s.id.as_pick()) + .collect::>() + }); + + // One ledger spans both disconnect phases: the overlap case (the range's parent-most + // sitting directly above the child-most's pick) captures the same edge in both frames. + let mut ledger = NumberLedger::default(); + // 2. Disconnect parents. + let (disconnected_parent_edges, carried_parent_tops) = self.disconnect_range_parents( + parent_node, + outgoing_edges, + parent_ids_to_disconnect.as_ref(), + &mut ledger, + )?; + // 3. Disconnect children and reconnect to the disconnected parents. + self.disconnect_range_children( + child_node, + incoming_edges, + child_ids_to_disconnect.as_ref(), + child_ref_edges.as_ref(), + child_ref_depth, + &moving_ref_children, + &carried_parent_tops, + &disconnected_parent_edges, + &mut ledger, + skip_reconnect_step, + ); + // 4. Re-adopt references left dangling on the range. + self.readopt_range_danglers(&disconnected_parent_edges); + + Ok(()) + } + + /// Phase 2 of [`Self::disconnect_range_from`]: disconnect the selected parents (`None` + /// = all) from `parent_node`. Groups the removed edges carried lose them from their + /// entering edges. Returns the removed edges as `(parent number, parent)` entries, plus the top + /// ref of each group a removed edge carried โ€” the interposed parent the edge pointed at + /// โ€” so disconnected child refs can stack above it. + #[expect(clippy::type_complexity)] + fn disconnect_range_parents( + &mut self, + parent_node: PickIndex, + outgoing_edges: Vec<(usize, PickIndex)>, + parent_ids_to_disconnect: Option<&HashSet>, + ledger: &mut NumberLedger, + ) -> Result<(Vec<(usize, PickIndex)>, Vec)> { + let mut disconnected_parent_edges: Vec<(usize, PickIndex)> = Vec::new(); + let mut carried_parent_tops: Vec = Vec::new(); + for (frame_number, edge_target) in outgoing_edges { + let should_disconnect = + parent_ids_to_disconnect.is_none_or(|ids| ids.contains(&edge_target)); + if should_disconnect { + // The ledger resolves the captured name to today's parent number (earlier removals + // shift edges down). Shifts preserve relative order, so the recorded parent numbers + // still sort the disconnected parents as their captured orders did. + let parent_number = ledger + .current(&self.graph, parent_node, frame_number) + .context("BUG: disconnected parent edge vanished")?; + let removed = (parent_node, parent_number); + // Groups this edge carried โ€” captured BEFORE it is removed, since the derived + // entering set reflects the live parent arrays. Removing it then drops the edge from + // every derived read automatically (no group bookkeeping needed). + let carried: Vec<_> = self + .graph + .positioned_refs() + .filter(|&entry| { + positions::edges_through(&self.graph, entry).contains(&removed) + }) + .collect(); + // The interposed parent this edge pointed at was the top of the group it + // carried โ€” remember it so disconnected child refs can stack above it. + if let Some(top) = carried + .iter() + .copied() + .filter(|&entry| { + positions::resolve_to_pick(&self.graph, entry) == Some(edge_target) + }) + .max_by_key(|&entry| (positions::ref_depth(&self.graph, entry), entry)) + { + carried_parent_tops.push(top); } + self.graph.remove_parent(parent_node, parent_number); + ledger.note_remove(parent_node, parent_number); + disconnected_parent_edges.push((parent_number, edge_target)); } } + Ok((disconnected_parent_edges, carried_parent_tops)) + } - if let Some(children_to_disconnect) = children_to_disconnect.as_ref() { - for selector in children_to_disconnect { - if !available_children.contains(&selector.id) { - return Err(anyhow!( - "Invalid parent delimitation: requested child is not a direct parent of target.child" - )); + /// Phase 3 of [`Self::disconnect_range_from`]: disconnect the selected children (`None` + /// = all) and reconnect them to the disconnected parents (unless + /// `skip_reconnect_step`), then move the groups that rode the range onto the first + /// disconnected parent. A `Some` `child_ref_edges` means the range's child bound was a + /// reference and holds the edges entering its group. + #[allow(clippy::too_many_arguments)] + fn disconnect_range_children( + &mut self, + child_node: PickIndex, + incoming_edges: Vec<(PickIndex, usize)>, + child_ids_to_disconnect: Option<&HashSet>, + child_ref_edges: Option<&Vec<(PickIndex, usize)>>, + child_ref_depth: Option, + moving_ref_children: &[RefIndex], + carried_parent_tops: &[RefIndex], + disconnected_parent_edges: &[(usize, PickIndex)], + ledger: &mut NumberLedger, + skip_reconnect_step: bool, + ) { + let full_child_disconnect = child_ids_to_disconnect.is_none(); + let mut sorted_disconnected = disconnected_parent_edges.to_vec(); + sorted_disconnected.sort_by_key(|(parent_number, _)| *parent_number); + // A rewired reference resolves through its first (lowest-parent number) disconnected parent. + let group_pick = sorted_disconnected.first().map(|(_, target)| *target); + for (edge_source, frame_number) in incoming_edges { + let should_disconnect = + child_ids_to_disconnect.is_none_or(|ids| ids.contains(&edge_source)); + if !should_disconnect { + continue; + } + // Earlier removals on the same child shift this edge down; the ledger resolves the + // captured name to the parent number the store uses now. + let Some(parent_number) = ledger.current(&self.graph, edge_source, frame_number) else { + // The parent loop already removed this edge: the range bounds can name + // overlapping edges when the range's parent-most sits directly above + // the child-most's pick. Only the reconnect still applies. + if !skip_reconnect_step { + self.reconnect_edges_to_parents(disconnected_parent_edges, edge_source); + } + continue; + }; + let carrying = self.graph.positioned_refs().any(|entry| { + positions::edges_through(&self.graph, entry).contains(&(edge_source, parent_number)) + && positions::resolve_to_pick(&self.graph, entry) == Some(child_node) + }); + if !skip_reconnect_step + && carrying + && child_ref_edges.is_none() + && !sorted_disconnected.is_empty() + { + // An edge that carried the target's group is an edge into the group โ€” it never + // lost its parent. Fan it out in place: the first disconnected parent takes + // the edge's parent number (the statement keeps its name, so the carried + // groups follow), the rest are inserted right after. + let mut targets = sorted_disconnected.iter().map(|(_, target)| *target); + self.graph.replace_parent( + edge_source, + parent_number, + targets.next().expect("non-empty"), + ); + for (offset, target) in targets.enumerate() { + self.graph + .insert_parent(edge_source, parent_number + 1 + offset, target); + ledger.note_insert(edge_source, parent_number + 1 + offset); } + continue; + } + // Remove the child edge; groups it carried lose it from their derived + // entering set automatically. + self.graph.remove_parent(edge_source, parent_number); + ledger.note_remove(edge_source, parent_number); + if skip_reconnect_step { + continue; } + // Reconnect the child entry to all the disconnected parents. + self.reconnect_edges_to_parents(disconnected_parent_edges, edge_source); } - - let parent_ids_to_disconnect = parents_to_disconnect - .as_ref() - .map(|parents| parents.iter().map(|s| s.id).collect::>()); - let child_ids_to_disconnect = children_to_disconnect - .as_ref() - .map(|children| children.iter().map(|s| s.id).collect::>()); - - let mut disconnected_parent_edges = Vec::new(); - // 2. Disconnect parents. - for (edge_id, edge_weight, edge_target) in outgoing_edges { - let should_disconnect = parent_ids_to_disconnect - .as_ref() - .is_none_or(|ids| ids.contains(&edge_target)); - if should_disconnect { - self.graph.remove_edge(edge_id); - disconnected_parent_edges.push((edge_weight, edge_target)); + // The target's groups were the interposed direct children of its pick: a full child + // disconnect rewires them onto the first disconnected parent, entering edges preserved. A + // reference child bound means the range INCLUDES that reference and its group + // at or below its rank โ€” those stay with the range. + if let Some(pick) = group_pick { + for moving_node in moving_ref_children { + transfer_stack(&mut self.graph, *moving_node, child_node, pick); } } - - // 3. Disconnect children and reconnect to the disconnected parents. - for (edge_id, _, edge_source) in incoming_edges { - let should_disconnect = child_ids_to_disconnect - .as_ref() - .is_none_or(|ids| ids.contains(&edge_source)); - if should_disconnect { - // Remove the child edge. - self.graph.remove_edge(edge_id); - // Reconnect the child node to all the disconnected parents. - if !skip_reconnect_step { - self.reconnect_edges_to_parents(&disconnected_parent_edges, edge_source); + if full_child_disconnect && let Some(pick) = group_pick { + match child_ref_edges { + None => { + // When the disconnected parent edge carried a group, the interposed parent + // was that group's top ref โ€” the child refs stack above it and follow it + // through later moves. Step 2 removed the edge that used to enter this + // group; step 3's reconnect added fresh edges into `pick`. The combined + // stack now sits behind all of those fresh edges (`GroupCarry::All`), which + // is also right when `pick` is a merge. + let landed = carried_parent_tops.first().is_some_and(|&top| { + land_stack_above(&mut self.graph, child_node, top, pick) + }); + if !landed { + positions::reposition_refs(&mut self.graph, child_node, pick, false); + } + } + Some(child_bound_edges) => { + // The bound and its group at or below its depth stay with the range; + // the group slice above it follows the pick move verbatim. + carry_stack_above( + &mut self.graph, + child_node, + child_bound_edges, + child_ref_depth.unwrap_or_default(), + pick, + ); } } } + } - Ok(()) + /// Phase 4 of [`Self::disconnect_range_from`]: references whose pick sat on the + /// now-disconnected range re-point to the range's first disconnected parent โ€” dangling + /// references follow where the commit's place went; their entering edges stay. + fn readopt_range_danglers(&mut self, disconnected_parent_edges: &[(usize, PickIndex)]) { + if let Some(onto) = disconnected_parent_edges.first().map(|(_, target)| *target) { + readopt_dangling_refs(&mut self.graph, onto); + } } - /// Remove the child edge, and reconnect to the right parents. + /// Reconnect `child_node` to the disconnected parents, appended after its existing + /// parents in their original relative order. fn reconnect_edges_to_parents( &mut self, - disconnected_parent_edges: &[(Edge, petgraph::prelude::NodeIndex)], - child_node: petgraph::prelude::NodeIndex, + disconnected_parent_edges: &[(usize, PickIndex)], + child_node: PickIndex, ) { - // Reconnect the child node to all the disconnected parents. - for (parent_edge_weight, edge_target) in disconnected_parent_edges { - self.graph - .add_edge(child_node, *edge_target, parent_edge_weight.clone()); + let mut disconnected_parent_edges = disconnected_parent_edges.iter().collect::>(); + disconnected_parent_edges.sort_by_key(|(parent_number, _)| *parent_number); + for (_, edge_target) in disconnected_parent_edges { + self.graph.push_parent(child_node, *edge_target); } } + /// Returns the parent number orders assigned to `new_parent_nodes`, in the given order. + /// Existing parent edges that get renumbered carry their group entries along. fn add_edges_to_parents( &mut self, - child_node: petgraph::prelude::NodeIndex, - new_parent_nodes: impl IntoIterator, + child_node: PickIndex, + new_parent_nodes: impl IntoIterator, parent_reparenting_order: ParentReparentingOrder, - ) { - let mut existing_parent_edges = self - .graph - .edges_directed(child_node, Direction::Outgoing) - .map(|edge| (edge.id(), edge.weight().order, edge.target())) - .collect::>(); - existing_parent_edges.sort_by_key(|(_, order, _)| *order); - - for (edge_id, _, _) in &existing_parent_edges { - self.graph.remove_edge(*edge_id); - } - - let new_parent_nodes = new_parent_nodes.into_iter().collect::>(); + ) -> Vec { match parent_reparenting_order { - ParentReparentingOrder::Prepend => { - for (order, parent_node) in new_parent_nodes.iter().enumerate() { + // Insertion-location parents define the first-parent line. Existing parents stay + // attached after them as merge-side parents. + ParentReparentingOrder::Prepend => new_parent_nodes + .into_iter() + .enumerate() + .map(|(parent_number, parent_node)| { self.graph - .add_edge(child_node, *parent_node, Edge { order }); - } - - // Insertion-location parents define the first-parent lane. Existing parents stay - // attached after them as merge-side parents. - let shifted_by = new_parent_nodes.len(); - for (offset, (_, _, parent_node)) in existing_parent_edges.into_iter().enumerate() { - self.graph.add_edge( - child_node, - parent_node, - Edge { - order: shifted_by + offset, - }, - ); - } - } - ParentReparentingOrder::Append => { - let shifted_by = existing_parent_edges.len(); - for (order, (_, _, parent_node)) in existing_parent_edges.into_iter().enumerate() { - self.graph.add_edge(child_node, parent_node, Edge { order }); - } - - for (offset, parent_node) in new_parent_nodes.into_iter().enumerate() { - self.graph.add_edge( - child_node, - parent_node, - Edge { - order: shifted_by + offset, - }, - ); - } - } + .insert_parent(child_node, parent_number, parent_node); + parent_number + }) + .collect(), + ParentReparentingOrder::Append => new_parent_nodes + .into_iter() + .map(|parent_node| self.graph.push_parent(child_node, parent_node)) + .collect(), } } - /// Insert a segment relative to a selector. - /// - /// `target` - Selector to insert the segment relative to. - /// - /// `delimiter` - The segment is described by its delimiter: First (parent-most) and last (child-most) node. - /// - /// `side` - The relative side to do the insertion. - /// - /// `nodes_to_connect` - Optional set of selector to connect instead of the parents/children determined. - /// - /// `parent_reparenting_order` - Controls how newly connected parent edges are ordered relative to - /// existing parent edges on the parent-most node of the inserted segment. With - /// [`ParentReparentingOrder::Prepend`], the newly connected parents become the lowest-order parents, - /// which makes the first inserted/reparented parent the first-parent traversal path. Existing parents - /// remain attached after them in their previous relative order. With - /// [`ParentReparentingOrder::Append`], existing parents keep the lowest parent orders and the newly - /// connected parents are appended after them. - /// - /// If `nodes_to_connect` is None: - /// If inserted above, all the target selector's children will be disconnected and reconnected to the last - /// node of the segment. If inserted below, all the target selector's parents will be disconnected and - /// reconnected to the parent-most node of the segment using `parent_reparenting_order`. - /// If `nodes_to_connect` is Some: - /// If inserted above, connect the given nodes as children. If inserted below, connect the given nodes as parents - /// using `parent_reparenting_order`. - /// - pub fn insert_segment_into( + /// Insert a range (parent-most `range.parent` up to child-most `range.child`) relative + /// to `target`. With `nodes_to_connect` unset, the target's children ([`InsertSide::Above`]) + /// or parents ([`InsertSide::Below`]) are disconnected and reconnected onto the range; + /// when set, those entries are connected instead. [`ParentReparentingOrder`] decides + /// whether newly connected parents go before or after existing parents on the range's + /// parent-most entry. + pub fn insert_range_into( &mut self, target: impl ToSelector, - delimiter: SegmentDelimiter, + range: StepRange, side: InsertSide, nodes_to_connect: Option, parent_reparenting_order: ParentReparentingOrder, @@ -613,307 +675,586 @@ impl Editor<'_, '_, M> { C: ToSelector, P: ToSelector, { - let SegmentDelimiter { child, parent } = delimiter; - let target = self.history.normalize_selector(target.to_selector(self)?)?; - let child = self.history.normalize_selector(child.to_selector(self)?)?; - let parent = self.history.normalize_selector(parent.to_selector(self)?)?; - - match side { - InsertSide::Above => { - // Find the child node of the highest order from the child-most node in the segment being inserted. - let chubbiest_grand_child = self - .graph - .edges_directed(child.id, Direction::Incoming) - .map(|e| (e.id(), e.weight().to_owned(), e.source())) - .max_by_key(|gc| gc.1.order); - - if let Some(nodes_to_connect) = nodes_to_connect { - // If there were nodes to connect defined, create edges from them into the child node of the segment - // being inserted. - for (index, any_selector) in nodes_to_connect.as_slice().iter().enumerate() { - let selector = any_selector.to_selector(self)?; - let node = self.history.normalize_selector(selector)?; - // Avoid weight collision by adding the order value of the highest order child plus one, - // accommodating for order 0. - let new_weight = if let Some((_, grand_child_weight, _)) = - chubbiest_grand_child.as_ref() - { - Edge { - order: index + grand_child_weight.order + 1, - } - } else { - Edge { order: index } - }; - self.graph.add_edge(node.id, child.id, new_weight); - } - } else { - let edges = self + let StepRange { child, parent } = range; + let target = target.to_selector(self)?; + let child = child.to_selector(self)?; + let parent = parent.to_selector(self)?; + + // An empty range โ€” a lone reference โ€” is pure position data: it folds into the + // target's group, and any entries to connect become the edges entering through it. + if child.id == parent.id && self.graph.is_positioned(child.id) { + self.ensure_mutable_ref(child.id)?; + let parent_number = match (side, self.graph.is_positioned(target.id)) { + (InsertSide::Above, true) => StackPlace::Above(ref_entry(target.id)?), + (InsertSide::Below, true) => StackPlace::Below(ref_entry(target.id)?), + (InsertSide::Above, false) => StackPlace::Bottom(node_entry(target.id)?), + (InsertSide::Below, false) => { + // On top of the group the target pick's first-parent edge enters. + let target_node = node_entry(target.id)?; + let first_parent = self .graph - .edges_directed(target.id, Direction::Incoming) - .map(|e| (e.id(), e.weight().to_owned(), e.source())) - .collect::>(); - - // Connect all target's children with the child-most node in the given segment. - for (edge_id, edge_weight, edge_source) in edges { - self.graph.remove_edge(edge_id); - // Avoid weight collision by adding the order value of the highest order child plus one, - // accommodating for order 0. - let new_weight = if let Some((_, grand_child_weight, _)) = - chubbiest_grand_child.as_ref() - { - Edge { - order: edge_weight.order + grand_child_weight.order + 1, - } - } else { - edge_weight - }; - self.graph.add_edge(edge_source, child.id, new_weight); + .parents(target_node) + .first() + .copied() + .context("Cannot insert a reference below a parentless commit")?; + StackPlace::GroupTop { + pick: first_parent, + edge: (target_node, 0), } } + }; + move_ref(&mut self.graph, ref_entry(child.id)?, parent_number); + if let Some(nodes_to_connect) = nodes_to_connect { + self.push_edges_onto(&nodes_to_connect, child)?; + } + return Ok(()); + } - // Connect the target to the parent-most node in the given segment according to - // the requested parent ordering policy. - self.add_edges_to_parents(parent.id, [target.id], parent_reparenting_order); - } - InsertSide::Below => { - let parents_to_add = if let Some(nodes_to_connect) = nodes_to_connect { - let mut nodes = Vec::new(); - for any_selector in nodes_to_connect.as_slice() { - let selector = any_selector.to_selector(self)?; - let node = self.history.normalize_selector(selector)?; - nodes.push(node.id); - } - nodes + match side { + InsertSide::Above => self.insert_range_above( + target, + child, + parent, + nodes_to_connect, + parent_reparenting_order, + ), + InsertSide::Below => self.insert_range_below( + target, + child, + parent, + nodes_to_connect, + parent_reparenting_order, + ), + } + } + + /// The [`InsertSide::Above`] arm of [`Self::insert_range_into`]: the range's child-most + /// takes over the target's children (or gains `nodes_to_connect` as children), and the + /// target connects under the range's parent-most. + fn insert_range_above( + &mut self, + target: Selector, + child: Selector, + parent: Selector, + nodes_to_connect: Option, + parent_reparenting_order: ParentReparentingOrder, + ) -> Result<()> { + if let Some(nodes_to_connect) = nodes_to_connect { + // Connect the given entries as children of the range's child-most. + // `push_edge` appends after existing parents and handles a reference + // child-most (the edge joins its group). + self.push_edges_onto(&nodes_to_connect, child)?; + } else if let Some(on) = self.graph.positioned_on(target.id) { + // Above a reference: split the group there. Members above move onto the + // range's child-most pick; the reference and members below are now + // entered through its parent-most pick. + let pick = self.resolved_pick(on)?; + let child_pick = positions::resolve_to_pick(&self.graph, child.id) + .context("Range child should resolve to a commit")?; + let parent_pick = positions::resolve_to_pick(&self.graph, parent.id) + .context("Range parent should resolve to a commit")?; + let target_edges = positions::edges_through(&self.graph, target.id); + let split = split_group( + &mut self.graph, + ref_entry(target.id)?, + SplitBoundary::Above, + child_pick, + ); + if !split.moved_any { + // The group's edges now enter through the range's child-most pick. + redirect_edges(&mut self.graph, &target_edges, pick, child_pick); + } + // Connect the parent-most entry to the reference's pick; a reference + // parent-most (an empty range) re-points instead of gaining edges. The + // target reference and members below are now entered through that edge. + let entry_number = if self.graph.is_positioned(parent.id) { + let pick_selector = self.new_selector(pick); + self.insert_edge(parent, pick_selector, 0)?; + // The range is positioned data: the split-off lower group is + // entered through the range's group, which ends at the pick. + 0 + } else { + let orders = self.add_edges_to_parents( + node_entry(parent.id)?, + [pick], + parent_reparenting_order, + ); + orders.first().copied().unwrap_or(0) + }; + settle_group_lower(&mut self.graph, &split.lower, (parent_pick, entry_number)); + return Ok(()); + } else { + // The range's child-most takes the target's place in each child's parent + // array: the parent number โ€” and any statement on it โ€” is untouched. + self.graph + .redirect_children(node_entry(target.id)?, node_entry(child.id)?); + // The target's groups slide under the range: refs sitting on the target + // move up onto the range's child-most pick. + if let Some(child_pick) = positions::resolve_to_pick(&self.graph, child.id) { + positions::reposition_refs( + &mut self.graph, + node_entry(target.id)?, + child_pick, + true, + ); + } + } + + // Connect the target to the parent-most entry in the given range according to + // the requested parent ordering policy. A reference target stands for its + // pick, with the new edge entering its group; a reference parent-most (an + // empty range) has no edges โ€” it re-points onto the target instead. + if self.graph.is_positioned(parent.id) { + self.insert_edge(parent, target, 0)?; + } else { + let connect_to = match self.graph.positioned_on(target.id) { + Some(on) => self.resolved_pick(on)?, + None => node_entry(target.id)?, + }; + let join = (EditorIndex::from(connect_to) != target.id) + .then(|| ref_entry(target.id)) + .transpose()? + .map(|r| positions::prepare_group_join(&self.graph, r)); + let parent_node = node_entry(parent.id)?; + let orders = + self.add_edges_to_parents(parent_node, [connect_to], parent_reparenting_order); + if let (Some(join), Some(order)) = (join, orders.first()) { + positions::apply_group_join(&mut self.graph, &join, (parent_node, *order)); + } + } + Ok(()) + } + + /// The [`InsertSide::Below`] arm of [`Self::insert_range_into`]: the target's parents + /// (or `nodes_to_connect`) become parents of the range's parent-most, and the range's + /// child-most connects under the target. + fn insert_range_below( + &mut self, + target: Selector, + child: Selector, + parent: Selector, + nodes_to_connect: Option, + parent_reparenting_order: ParentReparentingOrder, + ) -> Result<()> { + let mut moved_edge_orders = Vec::new(); + let mut ref_parents: Vec<(usize, RefIndex)> = Vec::new(); + let parents_to_add = if let Some(nodes_to_connect) = nodes_to_connect { + let mut entries = Vec::new(); + for any_selector in nodes_to_connect.as_slice() { + let entry = any_selector.to_selector(self)?; + // A reference parent: the pick edge goes to its pick and the edge + // joins its group once the final parent number is known. + if self.graph.is_positioned(entry.id) { + let pick = self.resolved_pick(entry.id)?; + ref_parents.push((entries.len(), ref_entry(entry.id)?)); + entries.push(pick); } else { - let mut edges = self - .graph - .edges_directed(target.id, Direction::Outgoing) - .map(|e| (e.id(), e.weight().order, e.target())) - .collect::>(); - edges.sort_by_key(|(_, order, _)| *order); - - let mut nodes = Vec::with_capacity(edges.len()); - for (edge_id, _, edge_target) in edges { - self.graph.remove_edge(edge_id); - nodes.push(edge_target); - } - nodes - }; + entries.push(node_entry(entry.id)?); + } + } + entries + } else if let Some(t_on) = self.graph.positioned_on(target.id) { + // A reference target's one downward link is its pick: the range goes + // between the reference and it. The reference's own re-pointing onto the + // range happens in the connect step below. + vec![self.resolved_pick(t_on)?] + } else { + // Statements stay named (target, parent number) until the rename below moves them + // onto the range's parent-most. + let drained = self.graph.drain_parents(node_entry(target.id)?); + moved_edge_orders = (0..drained.len()).collect(); + drained + }; - self.add_edges_to_parents(parent.id, parents_to_add, parent_reparenting_order); + // Ordering matters. A reference target connects BEFORE the range gains its own + // downward edge: re-pointing drags its entering edges along, and a pre-existing + // fresh edge (a `GroupCarry::All` ref sees every edge into its pick) would be dragged + // too and self-loop the range. A plain target connects AFTER: its orphaned edge + // statements must first be renamed onto the range's parent-most, or the fresh + // edge at parent number 0 would take their names. `parents_to_add` was captured up front, + // so it still names the pre-re-point target position. + let target_is_ref = self.graph.is_positioned(target.id); + if target_is_ref { + // A reference child-most stands for its pick, with the edge entering its group. + self.insert_edge(target, child, 0)?; + } - // Find the child node of the highest order from the child-most node in the segment being inserted. - let chubbiest_grand_child = self - .graph - .edges_directed(child.id, Direction::Incoming) - .map(|e| (e.id(), e.weight().to_owned(), e.source())) - .max_by_key(|gc| gc.1.order); - - let new_weight = - if let Some((_, grand_child_weight, _)) = chubbiest_grand_child.as_ref() { - Edge { - order: grand_child_weight.order + 1, - } - } else { - Edge { order: 0 } - }; - // Connect the target to the child-most node in the given segment. - self.graph.add_edge(target.id, child.id, new_weight); + // A reference parent-most (an empty range) has no edges โ€” it re-points + // onto its first new parent instead of gaining edges. + if self.graph.is_positioned(parent.id) { + if let Some(first) = parents_to_add.first() { + let first = self.new_selector(*first); + self.insert_edge(parent, first, 0)?; + } + } else { + let joins: Vec<_> = ref_parents + .iter() + .map(|(k, ref_node)| (*k, positions::prepare_group_join(&self.graph, *ref_node))) + .collect(); + let parent_node = node_entry(parent.id)?; + let new_orders = + self.add_edges_to_parents(parent_node, parents_to_add, parent_reparenting_order); + for (k, join) in &joins { + if let Some(order) = new_orders.get(*k) { + positions::apply_group_join(&mut self.graph, join, (parent_node, *order)); + } + } + // Groups those edges carried are now entered through the range's + // parent-most pick. Only a plain (drained) target has moved edges; a + // reference target contributes none. + if !moved_edge_orders.is_empty() { + let target_node = node_entry(target.id)?; + let renames: Vec<_> = moved_edge_orders + .iter() + .zip(new_orders) + .map(|(old, new)| ((target_node, *old), (parent_node, new))) + .collect(); + self.graph.rename_edges(&renames); } } + if !target_is_ref { + // A plain target keeps its existing parents in front; the range appends. + let parent_number = self.graph.parent_count(target.id); + self.insert_edge(target, child, parent_number)?; + } Ok(()) } - /// Insert a segment relative to a selector. - /// - /// The segment is described by its delimiter: First (parent-most) and last (child-most) node. - /// - /// If inserted above, all the target selector's children will be disconnected and reconnected to the last - /// node of the segment. - /// If inserted below, all the target selector's parents will be disconnected and reconnected to the - /// parent-most node of the segment. - /// - /// Reparented parents are prepended by default: newly connected parents receive the lowest parent orders, - /// so the first inserted/reparented parent becomes the first-parent traversal path and existing parents - /// remain attached after them in their previous relative order. Use [`Self::insert_segment_into`] with - /// [`ParentReparentingOrder::Append`] when existing parents should keep the lowest parent orders instead. - /// - pub fn insert_segment( + + /// [`Self::insert_range_into`] with no `nodes_to_connect` and + /// [`ParentReparentingOrder::Prepend`]: the target's children (above) or parents (below) + /// are reconnected onto the range, reparented parents becoming the first-parent line. + pub fn insert_range( &mut self, target: impl ToSelector, - delimiter: SegmentDelimiter, + range: StepRange, side: InsertSide, ) -> Result<()> where C: ToSelector, P: ToSelector, { - self.insert_segment_into( - target, - delimiter, - side, - None, - ParentReparentingOrder::Prepend, - ) + self.insert_range_into(target, range, side, None, ParentReparentingOrder::Prepend) } - /// Add a step node to the graph. - /// - /// Almost always you really want to use `insert` function instead. + /// Add a step to the graph, unconnected. Almost always you want [`Self::insert`] instead. pub fn add_step(&mut self, step: Step) -> Result { - let new_idx = self.graph.add_node(step); + let new_idx = add_step_to_graph(&mut self.graph, step); Ok(self.new_selector(new_idx)) } - /// Inserts a new node relative to a selector - /// - /// When inserting above, any nodes that point to the selector will now - /// point to the inserted node instead. When inserting below, any nodes - /// that the selector points to will now be pointed to by the inserted node - /// instead. - /// - /// Returns a selector to the inserted step + /// Insert a new step relative to `target` (see [`InsertSide`] for how edges rewire), + /// returning a selector to it. pub fn insert( &mut self, target: impl ToSelector, step: Step, side: InsertSide, ) -> Result { - let target = self.history.normalize_selector(target.to_selector(self)?)?; - match side { - InsertSide::Above => { - let edges = self - .graph - .edges_directed(target.id, Direction::Incoming) - .map(|e| (e.id(), e.weight().to_owned(), e.source())) - .collect::>(); + let target = target.to_selector(self)?; + let inserting_reference = matches!(step, Step::Reference { .. }); + let target_positioned = self.graph.is_positioned(target.id); + match (side, target_positioned) { + (InsertSide::Above, false) if !inserting_reference => { + // Above a pick: the interposed entry slides under the pick's groups โ€” its + // children rewire to the new entry with parent numbers preserved (so stored group + // edges stay valid) and every ref sitting on it moves up. + let Step::Pick(pick) = step else { + bail!("BUG: this arm inserts picks only"); + }; + let new_idx = self.graph.add_node(Some(pick)); + let target_node = node_entry(target.id)?; + self.graph.redirect_children(target_node, new_idx); + self.graph.push_parent(new_idx, target_node); + positions::reposition_refs(&mut self.graph, target_node, new_idx, false); - let new_idx = self.graph.add_node(step); - self.graph.add_edge(new_idx, target.id, Edge { order: 0 }); + Ok(self.new_selector(new_idx)) + } + (InsertSide::Above, false) => { + // A reference above a pick becomes the bottom of the pick's stack. + let new_idx = add_step_to_graph(&mut self.graph, step); + place_ref( + &mut self.graph, + ref_entry(new_idx)?, + StackPlace::Bottom(node_entry(target.id)?), + ); + Ok(self.new_selector(new_idx)) + } + (InsertSide::Above, true) if !inserting_reference => { + // A pick above a reference splits the group at that reference: members above + // move onto the new pick, the reference and members below are now entered + // through it. + let on = self + .graph + .positioned_on(target.id) + .expect("checked positioned above"); + self.interpose_pick_at_ref(target, on, step, SplitBoundary::Above) + } + (InsertSide::Above, true) => { + // A reference above a reference joins its group one rank up. + let new_idx = add_step_to_graph(&mut self.graph, step); + place_ref( + &mut self.graph, + ref_entry(new_idx)?, + StackPlace::Above(ref_entry(target.id)?), + ); + Ok(self.new_selector(new_idx)) + } + (InsertSide::Below, false) if !inserting_reference => { + // Below a pick: the pick's whole parent array moves onto the new entry with + // parent numbers preserved, so groups carried by those edges follow the rename. + let Step::Pick(pick) = step else { + bail!("BUG: this arm inserts picks only"); + }; + let target_node = node_entry(target.id)?; + let new_idx = self.graph.add_node(Some(pick)); + self.graph.transplant_parents(target_node, new_idx); + self.graph.push_parent(target_node, new_idx); - for (edge_id, edge_weight, edge_source) in edges { - self.graph.remove_edge(edge_id); - self.graph.add_edge(edge_source, new_idx, edge_weight); + Ok(self.new_selector(new_idx)) + } + (InsertSide::Below, false) => { + // A reference below a pick sits on top of the group the pick's first-parent + // edge enters (or starts one). + let target_node = node_entry(target.id)?; + let first_parent = self.graph.parents(target_node).first().copied(); + let new_idx = add_step_to_graph(&mut self.graph, step); + if let Some(parent_pick) = first_parent { + place_ref( + &mut self.graph, + ref_entry(new_idx)?, + StackPlace::GroupTop { + pick: parent_pick, + edge: (target_node, 0), + }, + ); } - Ok(self.new_selector(new_idx)) } - InsertSide::Below => { - let edges = self + (InsertSide::Below, true) if !inserting_reference => { + // A pick below a reference splits the group there: the reference and members + // above re-point onto the new pick, members below are entered through it. + self.ensure_mutable_ref(target.id)?; + let on = self .graph - .edges_directed(target.id, Direction::Outgoing) - .map(|e| (e.id(), e.weight().to_owned(), e.target())) - .collect::>(); + .positioned_on(target.id) + .expect("checked positioned above"); + self.interpose_pick_at_ref(target, on, step, SplitBoundary::At) + } + (InsertSide::Below, true) => { + // A reference below a reference takes its position; it and everything above + // shift up. + let new_idx = add_step_to_graph(&mut self.graph, step); + place_ref( + &mut self.graph, + ref_entry(new_idx)?, + StackPlace::Below(ref_entry(target.id)?), + ); + Ok(self.new_selector(new_idx)) + } + } + } - let new_idx = self.graph.add_node(step); - self.graph.add_edge(target.id, new_idx, Edge { order: 0 }); + /// Bail when `entry` is an immutable reference (e.g. a remote-tracking ref): + /// materialization would refuse the write, so the op fails up front instead of + /// succeeding session-only. + fn ensure_mutable_ref(&self, entry: EditorIndex) -> Result<()> { + if let Some(record) = self.graph.state_of(entry) + && !record.mutable + { + bail!( + "reference {} is immutable and cannot be moved, renamed, or deleted", + record.refname + ); + } + Ok(()) + } - for (edge_id, edge_weight, edge_target) in edges { - self.graph.remove_edge(edge_id); - self.graph.add_edge(new_idx, edge_target, edge_weight); - } + /// The pick `entry` resolves to; an error when it resolves to nothing (an unborn ref). + pub(crate) fn resolved_pick(&self, entry: impl Into) -> Result { + positions::resolve_to_pick(&self.graph, entry) + .context("Reference target should resolve to a commit") + } - Ok(self.new_selector(new_idx)) - } + /// [`Self::push_edge`] from each of `entries` onto `parent`. + fn push_edges_onto(&mut self, entries: &SomeSelectors, parent: Selector) -> Result<()> { + for any_selector in entries.as_slice() { + let entry = any_selector.to_selector(self)?; + self.push_edge(entry, parent)?; } + Ok(()) } - /// Add an edge to the graph with a desired order. + /// Interpose a new pick between `target` (a reference positioned `on` its pick) and that + /// pick: split the target's group at `boundary`, rest the split-off lower part on the new + /// pick, and redirect the entering edges when they should now enter through it โ€” always + /// for an `At` split; for an `Above` split only when nothing moved up (the target was the + /// group's top, so the whole group now rests on the new pick). + fn interpose_pick_at_ref( + &mut self, + target: Selector, + on: PickIndex, + step: Step, + boundary: SplitBoundary, + ) -> Result { + let pick = self.resolved_pick(on)?; + let Step::Pick(new_pick) = step else { + bail!("BUG: interposing inserts picks only"); + }; + // Capture the target's entering edges BEFORE adding the new pick's edge, so the + // fresh edge does not leak into `target_edges` (the redirect would then re-point + // it onto the new pick itself, a self-loop). + let target_edges = positions::edges_through(&self.graph, target.id); + let target_ref = ref_entry(target.id)?; + let new_idx = self.graph.add_node(Some(new_pick)); + self.graph.push_parent(new_idx, pick); + let split = split_group(&mut self.graph, target_ref, boundary, new_idx); + settle_group_lower(&mut self.graph, &split.lower, (new_idx, 0)); + if matches!(boundary, SplitBoundary::At) || !split.moved_any { + // The group's edges now enter through the new pick, parent numbers untouched. + redirect_edges(&mut self.graph, &target_edges, pick, new_idx); + } + Ok(self.new_selector(new_idx)) + } + + /// Append an edge from `child` to `parent` after `child`'s existing parents. + /// + /// Reference endpoints behave as in [`Self::insert_edge`]. + pub fn push_edge(&mut self, child: impl ToSelector, parent: impl ToSelector) -> Result<()> { + self.insert_edge(child, parent, usize::MAX) + } + + /// Insert an edge from `child` to `parent` at `parent number` among `child`'s ordered parents + /// (clamped to the end); parents at `parent number` and later shift up, statements following. /// - /// Bails if there is already an edge from the child to the parent with the - /// same order. - pub fn add_edge( + /// An edge FROM a reference positions it at the parent, never a raw edge (references + /// are positions): a live reference re-points through the layout machinery; a dead one + /// (which upstream-integration retention still redirects) just re-points its retained + /// position โ€” no group cascade, since its stored position is stale. An edge INTO a + /// reference enters its group: the pick edge goes to the pick and the reference (with + /// members below it) gains the new edge. + pub fn insert_edge( &mut self, child: impl ToSelector, parent: impl ToSelector, - desired_order: usize, + parent_number: usize, ) -> Result<()> { - let child = self.history.normalize_selector(child.to_selector(self)?)?; - let parent = self.history.normalize_selector(parent.to_selector(self)?)?; + let child = child.to_selector(self)?; + let parent = parent.to_selector(self)?; + self.debug_assert_acyclic(child.id, parent.id)?; + + if self.graph.state_of(child.id).is_some() { + // An edge FROM a reference re-points it; a reference PARENT merely gains an + // entering edge and may stay immutable. + self.ensure_mutable_ref(child.id)?; + let onto = match self.graph.positioned_on(parent.id) { + Some(parent_on) => self.resolved_pick(parent_on)?, + None => node_entry(parent.id)?, + }; + let child_ref = ref_entry(child.id)?; + if self.graph.is_reference(child_ref) { + repoint_ref(&mut self.graph, child_ref, onto); + } else { + self.graph.set_retained_position(child_ref, onto); + } + return Ok(()); + } + let parent_is_ref = self.graph.is_positioned(parent.id); + let parent_pick = match self.graph.positioned_on(parent.id) { + Some(on) => self.resolved_pick(on)?, + None => node_entry(parent.id)?, + }; + let child_node = node_entry(child.id)?; + let parent_number = self + .graph + .insert_parent(child_node, parent_number, parent_pick); + // The group is captured AFTER the insert: normalization and the shift rename the + // child's statements, so a pre-capture would hold stale edge names. + if parent_is_ref { + let join = positions::prepare_group_join(&self.graph, ref_entry(parent.id)?); + positions::apply_group_join(&mut self.graph, &join, (child_node, parent_number)); + } + Ok(()) + } + fn debug_assert_acyclic(&self, child: EditorIndex, parent: EditorIndex) -> Result<()> { if cfg!(debug_assertions) { - let mut seen = HashSet::from([parent.id]); - let mut tips = vec![parent.id]; + let mut seen = HashSet::from([parent]); + let mut tips = vec![parent]; while let Some(tip) = tips.pop() { - for parent in self - .graph - .edges_directed(tip, Direction::Outgoing) - .map(|e| e.target()) - { - if seen.insert(parent) { - tips.push(parent); + for parent in self.graph.parents(tip) { + if seen.insert(parent.into()) { + tips.push(parent.into()); } } } - if seen.contains(&child.id) { + if seen.contains(&child) { bail!("BUG: Add edge introduces a cycle"); } } - - if self - .graph - .edges_directed(child.id, Direction::Outgoing) - .any(|edge| edge.weight().order == desired_order) - { - bail!("An edge with desired order {desired_order} already exists"); - } - - self.graph.add_edge( - child.id, - parent.id, - Edge { - order: desired_order, - }, - ); - Ok(()) } - /// Removes all edges between a child and parent, returning the orders of the removed edges. + /// Removes all edges between a child and parent, returning the (pre-removal, ascending) + /// parent numbers they occupied. Later parents shift down, statements following. pub fn remove_edges( &mut self, child: impl ToSelector, parent: impl ToSelector, ) -> Result> { - let child = self.history.normalize_selector(child.to_selector(self)?)?; - let parent = self.history.normalize_selector(parent.to_selector(self)?)?; - - let edges = self - .graph - .edges_directed(child.id, Direction::Outgoing) - .filter_map(|e| (e.target() == parent.id).then_some(e.id())) - .collect::>(); - - let mut orders = vec![]; - for edge in edges { - let weight = self + let child = child.to_selector(self)?; + let parent = parent.to_selector(self)?; + + // A reference child holds one conceptual downward edge (order 0) โ€” its pick. It is + // reported but not cleared; a follow-up insert_edge re-points, and a position without + // a resolving pick is not representable. + if self.graph.is_positioned(child.id) { + let resolves_to_parent = positions::resolve_to_pick(&self.graph, child.id) + == positions::resolve_to_pick(&self.graph, parent.id); + return Ok(if resolves_to_parent { vec![0] } else { vec![] }); + } + let numbers = match self.graph.positioned_on(parent.id) { + // Disconnecting from a reference removes the edges carrying its group. + Some(on) => { + let target_pick = self.resolved_pick(on)?; + let parent_edges = positions::edges_through(&self.graph, parent.id); + let child_node = node_entry(child.id)?; + self.graph + .parents(child_node) + .iter() + .copied() + .enumerate() + .filter_map(|(parent_number, target)| { + (target == target_pick + && parent_edges.contains(&(child_node, parent_number))) + .then_some(parent_number) + }) + .collect::>() + } + // Disconnecting from a pick removes its parent numbers; groups riding a removed edge lose + // it from their entering edges below. + None => self .graph - .remove_edge(edge) - .context("BUG: Failed to remove edge")?; + .parents(child.id) + .iter() + .copied() + .enumerate() + .filter_map(|(parent_number, target)| { + (EditorIndex::from(target) == parent.id).then_some(parent_number) + }) + .collect::>(), + }; - orders.push(weight.order); + // Highest-first so earlier parent numbers keep their names; report the pre-removal parent numbers. + let child_node = node_entry(child.id)?; + for parent_number in numbers.iter().rev() { + self.graph + .remove_parent(child_node, *parent_number) + .context("BUG: Failed to remove parent")?; } - Ok(orders) - } -} - -#[cfg(test)] -mod test { - - use super::*; - - #[test] - fn empty_selector_set_creation_fails() { - let empty_parent_set = SomeSelectors::new(Vec::::new()) - .expect_err("expected empty selector set creation to fail"); - assert!( - empty_parent_set - .to_string() - .contains("Invalid selector set: This cannot be empty"), - "unexpected error: {empty_parent_set:#}" - ); + Ok(numbers) } } diff --git a/crates/but-rebase/src/graph_rebase/ordering.rs b/crates/but-rebase/src/graph_rebase/ordering.rs index 05bcda6e632..b67611bd4d9 100644 --- a/crates/but-rebase/src/graph_rebase/ordering.rs +++ b/crates/but-rebase/src/graph_rebase/ordering.rs @@ -4,17 +4,16 @@ use std::collections::{HashMap, HashSet}; use anyhow::{Result, bail}; use but_core::RefMetadata; -use petgraph::Direction; -use crate::graph_rebase::{Editor, Pick, Selector, Step, StepGraphIndex, ToCommitSelector, util}; +use crate::graph_rebase::{Editor, PickIndex, Selector, ToCommitSelector, util}; -impl Editor<'_, '_, M> { +impl Editor<'_, M> { /// Order commit selectors by parentage, with parents first and children last. /// /// Duplicate selectors are deduplicated by commit-id with first occurrence winning. /// - /// Ordering is derived from a deterministic rank map built from the editor step graph. - /// The rank is computed by traversing from all child-most graph nodes in ordered-parent + /// Ordering is derived from a deterministic rank map built from the editor graph. + /// The rank is computed by traversing from all child-most graph entries in ordered-parent /// post-order (parents are pushed in `collect_ordered_parents` order, without reversing), /// then sorting selected commits by `(rank, input_order)`. /// @@ -43,32 +42,26 @@ impl Editor<'_, '_, M> { return Ok(selected.into_iter().map(|s| s.selector).collect()); } - // Build a deterministic rank from editor step-graph order. + // Build a deterministic rank from editor commit-graph order. let selected_ids = selected .iter() .map(|commit| commit.id) .collect::>(); - let step_graph_rank = step_graph_parent_to_child_rank(self, &selected_ids)?; + let graph_rank = parent_to_child_rank(self, &selected_ids)?; // Preserve the Result contract: unreachable selected commits are a runtime error, // not an internal panic. for commit in &selected { - if !step_graph_rank.contains_key(&commit.id) { + if !graph_rank.contains_key(&commit.id) { bail!( - "Cannot order selected commits by parentage: selected commit {} could not be ranked from editor graph nodes", + "Cannot order selected commits by parentage: selected commit {} could not be ranked from editor graph entries", commit.id ); } } // The rank map is the sole source of truth for deterministic parent-before-child ordering. - selected.sort_by_key(|commit| { - let rank = step_graph_rank - .get(&commit.id) - .copied() - .unwrap_or(usize::MAX); - (rank, commit.input_order) - }); + selected.sort_by_key(|commit| (graph_rank[&commit.id], commit.input_order)); Ok(selected.into_iter().map(|s| s.selector).collect()) } @@ -81,23 +74,20 @@ struct SelectedCommit { input_order: usize, } -fn step_graph_parent_to_child_rank( - editor: &Editor<'_, '_, M>, +fn parent_to_child_rank( + editor: &Editor<'_, M>, selected_ids: &HashSet, ) -> Result> { let mut rank_by_id = HashMap::::new(); let mut next_rank = 0usize; - let mut seen = HashSet::::new(); + let mut seen = HashSet::::new(); - let mut roots = editor - .graph - .externals(Direction::Incoming) - .collect::>(); - roots.sort_unstable_by_key(|idx| idx.index()); + let mut roots = editor.graph.tips().collect::>(); + roots.sort_unstable(); - // Traverse from all child-most entrypoints (graph nodes without children), assigning + // Traverse from all child-most entrypoints (graph entries without children), assigning // rank in post-order so parent commits always rank before descendants. Parents are - // pushed in collect_ordered_parents order (not reversed). The seen-set handles nodes + // pushed in collect_ordered_parents order (not reversed). The seen-set handles entries // reachable from multiple entrypoints, and traversal stops once all selected commits // have ranks. for root in roots { @@ -106,13 +96,13 @@ fn step_graph_parent_to_child_rank( } let mut stack = vec![(root, false)]; - while let Some((node, expanded)) = stack.pop() { + while let Some((entry, expanded)) = stack.pop() { if rank_by_id.len() == selected_ids.len() { break; } if expanded { - if let Step::Pick(Pick { id, .. }) = editor.graph[node] + if let Some(id) = editor.graph.commit_id(entry) && selected_ids.contains(&id) { rank_by_id.entry(id).or_insert_with(|| { @@ -124,12 +114,12 @@ fn step_graph_parent_to_child_rank( continue; } - if !seen.insert(node) { + if !seen.insert(entry) { continue; } - let parents = util::collect_ordered_parents(&editor.graph, node); - stack.push((node, true)); + let parents = util::collect_ordered_parents(&editor.graph, entry); + stack.push((entry, true)); for parent_idx in parents.into_iter() { stack.push((parent_idx, false)); } diff --git a/crates/but-rebase/src/graph_rebase/rebase.rs b/crates/but-rebase/src/graph_rebase/rebase.rs index 98742f82ff7..2db15726733 100644 --- a/crates/but-rebase/src/graph_rebase/rebase.rs +++ b/crates/but-rebase/src/graph_rebase/rebase.rs @@ -1,7 +1,7 @@ //! Perform the actual rebase operations use std::{ - collections::{HashMap, HashSet, VecDeque}, + collections::{HashSet, VecDeque}, fmt::Write as _, }; @@ -11,196 +11,146 @@ use gix::refs::{ Target, transaction::{Change, LogChange, PreviousValue, RefEdit}, }; -use petgraph::{Direction, visit::EdgeRef}; +use crate::graph_rebase::graph_editor::PickIndex; use crate::graph_rebase::{ - Editor, Pick, Step, StepGraph, StepGraphIndex, SuccessfulRebase, + Editor, GraphEditor, Step, SuccessfulRebase, cherry_pick::{CherryPickOutcome, cherry_pick}, util::collect_ordered_parents, }; -impl<'ws, 'graph, M: RefMetadata> Editor<'ws, 'graph, M> { - /// Perform the rebase - pub fn rebase(self) -> Result> { - // First we want to get a list of nodes that can be reached by - // traversing downwards from the heads that we care about. - // Usually there would be just one "head" which is an index to access - // the reference step for `gitbutler/workspace`, but there could be - // multiple. +impl<'meta, M: RefMetadata> Editor<'meta, M> { + /// Perform the rebase IN PLACE: each mutable pick's commit id is rewritten where it + /// stands, in dependency order, so a pick's parents already hold rebased ids by the + /// time it is picked. Entry ids never change โ€” parent arrays, positions, groups, and every + /// outstanding selector stay valid across the rebase. + #[tracing::instrument(level = "debug", skip_all, err(Debug))] + pub fn rebase(self) -> Result> { + crate::graph_rebase::positions::debug_assert_positions_total(&self.graph); + let mut graph = self.graph; + let mut history = self.history; let mut ref_edits = vec![]; - // Every external (a node with no children) seeds the traversal so the - // output graph keeps every commit - immutable picks are copied verbatim - // rather than cherry-picked. - let rebase_heads = self - .graph - .externals(Direction::Incoming) - .collect::>(); - let steps_to_pick = order_steps_picking(&self.graph, &rebase_heads); - - // A 1 to 1 mapping between the incoming graph and the output graph - let mut graph_mapping: HashMap = HashMap::new(); - // The step graph with updated commit oids - let mut output_graph = StepGraph::new(); let mut unchanged_references = vec![]; - let mut history = self.history; + // Every tip (an entry with no children) seeds the traversal so every commit is + // visited โ€” immutable picks and tombstones are left untouched where they stand. + let rebase_heads = graph.tips().collect::>(); + let steps_to_pick = order_steps_picking(&graph, &rebase_heads); for step_idx in steps_to_pick { - // Do the frikkin rebase man! - let step = self.graph[step_idx].clone(); - let new_idx = match step { - Step::Pick(pick) if !pick.mutable => { - // Immutable picks are copied verbatim: the commit keeps its - // id, so there's no cherry-pick to run and nothing to record - // in the history mapping. - output_graph.add_node(Step::Pick(pick)) - } - Step::Pick(pick) => { - let graph_parents = collect_ordered_parents(&self.graph, step_idx); - let ontos = match pick.preserved_parents.clone() { - Some(ontos) => ontos, - None => graph_parents - .iter() - .map(|idx| { - let Some(new_idx) = graph_mapping.get(idx) else { - bail!("A matching parent can't be found in the output graph"); - }; - - match output_graph[*new_idx] { - Step::Pick(Pick { id, .. }) => Ok(id), - _ => bail!("A parent in the output graph is not a pick"), - } - }) - .collect::>>()?, - }; - - let outcome = cherry_pick( - &self.repo, - pick.id, - &ontos, - pick.pick_mode, - pick.tree_merge_mode, - pick.sign_commit, - )?; - - if matches!(outcome, CherryPickOutcome::ConflictedCommit(_)) - && !pick.conflictable - { - bail!( - "Commit {} was marked as not conflictable, but resulted in a conflicted state", - pick.id - ); - } - - match outcome { - CherryPickOutcome::Commit(new_id) - | CherryPickOutcome::ConflictedCommit(new_id) - | CherryPickOutcome::Identity(new_id) => { - let mut new_pick = pick.clone(); - new_pick.id = new_id; - let new_idx = output_graph.add_node(Step::Pick(new_pick)); - graph_mapping.insert(step_idx, new_idx); - if !pick.exclude_from_tracking { - history.update_mapping(pick.id, new_id); - } - - new_idx - } - CherryPickOutcome::FailedToMergeBases { - base_merge_failed, - bases, - onto_merge_failed, - ontos, - } => { - // Exit early - the rebase failed because it encountered a commit it couldn't pick - bail!(format_base_merge_error( - pick.id, - base_merge_failed, - bases, - onto_merge_failed, - ontos - )); - } - } - } - Step::Reference { refname, mutable } => { - // Immutable references are kept in the graph for traversal - // but never moved, created, or deleted. - if mutable { - let graph_parents = collect_ordered_parents(&self.graph, step_idx); - let first_parent_idx = graph_parents - .first() - .context("References should have at least one parent")?; - let Some(new_idx) = graph_mapping.get(first_parent_idx) else { - bail!("A matching parent can't be found in the output graph"); - }; - - let to_reference = match output_graph[*new_idx] { - Step::Pick(Pick { id, .. }) => id, - _ => bail!("A parent in the output graph is not a pick"), - }; - - let reference = self.repo.try_find_reference(&refname)?; - - if let Some(reference) = reference { - let target = reference.target(); - match target { - gix::refs::TargetRef::Object(id) => { - if id == to_reference { - unchanged_references.push(refname.clone()); - } else { - ref_edits.push(RefEdit { - name: refname.clone(), - change: Change::Update { - log: LogChange::default(), - expected: PreviousValue::MustExistAndMatch( - target.into(), - ), - new: Target::Object(to_reference), - }, - deref: false, - }); - } - } - gix::refs::TargetRef::Symbolic(name) => { - bail!("Attempted to update the symbolic reference {name}"); - } - } - } else { - ref_edits.push(RefEdit { - name: refname.clone(), - change: Change::Update { - log: LogChange::default(), - expected: PreviousValue::MustNotExist, - new: Target::Object(to_reference), - }, - deref: false, - }); - } - } - - output_graph.add_node(Step::Reference { refname, mutable }) - } - Step::None => output_graph.add_node(Step::None), + let Step::Pick(pick) = graph.step_view(step_idx.into()) else { + // Tombstones have nothing to rewrite. + continue; }; + if !pick.mutable { + // Immutable picks keep their id: no cherry-pick to run, nothing to record + // in the history mapping. + continue; + } - graph_mapping.insert(step_idx, new_idx); + // Only resolve the graph parents when we actually need them โ€” a pick with + // `preserved_parents` already carries its onto-commits. + let ontos = match pick.preserved_parents.clone() { + Some(ontos) => ontos, + None => collect_ordered_parents(&graph, step_idx) + .iter() + .map(|&idx| { + graph + .commit_id(idx) + .context("BUG: ordered parents must be picks") + }) + .collect::>>()?, + }; - let mut edges = self - .graph - .edges_directed(step_idx, petgraph::Direction::Outgoing) - .collect::>(); - edges.sort_by_key(|e| e.weight().order); - edges.reverse(); + let outcome = cherry_pick( + &self.repo, + pick.id, + &ontos, + pick.pick_mode, + pick.tree_merge_mode, + pick.sign_commit, + )?; + + if matches!(outcome, CherryPickOutcome::ConflictedCommit(_)) && !pick.conflictable { + bail!( + "Commit {} was marked as not conflictable, but resulted in a conflicted state", + pick.id + ); + } - for e in edges { - let Some(new_parent) = graph_mapping.get(&e.target()) else { - bail!("Failed to find corresponding parent"); - }; + match outcome { + CherryPickOutcome::Commit(new_id) + | CherryPickOutcome::ConflictedCommit(new_id) + | CherryPickOutcome::Identity(new_id) => { + graph.set_commit_id(step_idx, new_id); + if !pick.exclude_from_tracking { + history.update_mapping(pick.id, new_id); + } + } + CherryPickOutcome::FailedToMergeBases { + base_merge_failed, + bases, + onto_merge_failed, + ontos, + } => { + // Exit early - the rebase failed because it encountered a commit it couldn't pick + bail!(format_base_merge_error( + pick.id, + base_merge_failed, + bases, + onto_merge_failed, + ontos + )); + } + } + } - output_graph.add_edge(new_idx, *new_parent, e.weight().clone()); + // References need no rewrite at all โ€” their position's `on` entry now carries the + // rebased id. All that remains is emitting the ref transaction: every live, mutable, + // positioned reference moves to its pick's new commit. + for step_idx in graph.ref_indices() { + let record = graph + .state_of(step_idx.into()) + .expect("ref_indices only yields references"); + if !record.live || !record.mutable || !graph.is_positioned(step_idx) { + // Dead records keep their retained name and position; immutable references + // are kept in the graph for traversal but never moved, created, or deleted. + continue; } + let refname = record.refname.clone(); + let first_parent_idx = + crate::graph_rebase::positions::resolve_to_pick(&graph, step_idx) + .context("References should resolve to a commit")?; + let to_reference = match graph.commit_id(first_parent_idx) { + Some(id) => id, + None => bail!("A reference's position does not resolve to a pick"), + }; + + let expected = match self.repo.try_find_reference(&refname)? { + Some(reference) => match reference.target() { + gix::refs::TargetRef::Object(id) if id == to_reference => { + unchanged_references.push(refname); + continue; + } + target @ gix::refs::TargetRef::Object(_) => { + PreviousValue::MustExistAndMatch(target.into()) + } + gix::refs::TargetRef::Symbolic(name) => { + bail!("Attempted to update the symbolic reference {name}"); + } + }, + None => PreviousValue::MustNotExist, + }; + ref_edits.push(RefEdit { + name: refname, + change: Change::Update { + log: LogChange::default(), + expected, + new: Target::Object(to_reference), + }, + deref: false, + }); } // Find deleted references. `initial_references` only contains mutable @@ -224,16 +174,14 @@ impl<'ws, 'graph, M: RefMetadata> Editor<'ws, 'graph, M> { } } - history.add_revision(graph_mapping); - Ok(SuccessfulRebase { repo: self.repo, initial_references: self.initial_references, ref_edits, - graph: output_graph, - checkouts: self.checkouts.to_owned(), + graph, + checkouts: self.checkouts, history, - workspace: self.workspace, + project_meta: self.project_meta, meta: self.meta, }) } @@ -248,24 +196,26 @@ impl<'ws, 'graph, M: RefMetadata> Editor<'ws, 'graph, M> { /// Then, we do a second traversal up from those bottom most /// steps. /// -/// This second traversal ensures that all the parents of any given node have +/// This second traversal ensures that all the parents of any given entry have /// been seen, before traversing it. -fn order_steps_picking(graph: &StepGraph, heads: &[StepGraphIndex]) -> VecDeque { - let mut heads = heads.to_vec(); - let mut seen = heads.iter().cloned().collect::>(); - // Reachable nodes with no outgoing nodes. +fn order_steps_picking(graph: &GraphEditor, heads: &[PickIndex]) -> VecDeque { + // References take no part in the pick order (no edges, replayed separately) โ€” + // the head type keeps them out. Picks AND tombstones must all be traversed, + // or their subtree is orphaned. + let mut heads: Vec = heads.to_vec(); + let mut seen = heads.iter().cloned().collect::>(); + // Reachable entries with no parents. let mut bases = VecDeque::new(); while let Some(head) = heads.pop() { - let edges = graph.edges_directed(head, petgraph::Direction::Outgoing); + let parents = graph.parents(head); - if edges.clone().count() == 0 { + if parents.is_empty() { bases.push_back(head); continue; } - for edge in edges { - let t = edge.target(); + for t in parents { if seen.insert(t) { heads.push(t); } @@ -278,12 +228,9 @@ fn order_steps_picking(graph: &StepGraph, heads: &[StepGraphIndex]) -> VecDeque< let mut retraversed = bases.iter().cloned().collect::>(); while let Some(base) = bases.pop_front() { - for edge in graph.edges_directed(base, petgraph::Direction::Incoming) { - // We only want to queue nodes for traversing that have had all of their parents traversed. - let s = edge.source(); - let mut outgoing_edges = graph.edges_directed(s, petgraph::Direction::Outgoing); - let all_parents_seen = outgoing_edges.clone().count() == 0 - || outgoing_edges.all(|e| retraversed.contains(&e.target())); + for &(s, _) in graph.incoming_edges(base) { + // We only want to queue entries for traversing that have had all of their parents traversed. + let all_parents_seen = graph.parents(s).iter().all(|t| retraversed.contains(t)); if all_parents_seen && seen.contains(&s) && retraversed.insert(s) { bases.push_back(s); ordered.push_back(s); @@ -347,24 +294,24 @@ mod test { use anyhow::Result; use crate::graph_rebase::{ - Edge, Step, StepGraph, rebase::order_steps_picking, testing::render_ascii_graph, + GraphEditor, Pick, rebase::order_steps_picking, testing::render_ascii_graph, }; #[test] fn basic_scenario() -> Result<()> { - let mut graph = StepGraph::new(); - let a = graph.add_node(Step::new_pick(gix::ObjectId::from_str( + let mut graph = GraphEditor::default(); + let a = graph.add_node(Some(Pick::new_pick(gix::ObjectId::from_str( "1000000000000000000000000000000000000000", - )?)); - let b = graph.add_node(Step::new_pick(gix::ObjectId::from_str( + )?))); + let b = graph.add_node(Some(Pick::new_pick(gix::ObjectId::from_str( "2000000000000000000000000000000000000000", - )?)); - let c = graph.add_node(Step::new_pick(gix::ObjectId::from_str( + )?))); + let c = graph.add_node(Some(Pick::new_pick(gix::ObjectId::from_str( "3000000000000000000000000000000000000000", - )?)); + )?))); - graph.add_edge(a, b, Edge { order: 0 }); - graph.add_edge(b, c, Edge { order: 0 }); + graph.push_parent(a, b); + graph.push_parent(b, c); snapbox::assert_data_eq!( render_ascii_graph(&graph, |_| None), @@ -387,49 +334,49 @@ mod test { #[test] fn complex_scenario() -> Result<()> { - let mut graph = StepGraph::new(); - let a = graph.add_node(Step::new_pick(gix::ObjectId::from_str( + let mut graph = GraphEditor::default(); + let a = graph.add_node(Some(Pick::new_pick(gix::ObjectId::from_str( "1000000000000000000000000000000000000000", - )?)); - let b = graph.add_node(Step::new_pick(gix::ObjectId::from_str( + )?))); + let b = graph.add_node(Some(Pick::new_pick(gix::ObjectId::from_str( "2000000000000000000000000000000000000000", - )?)); - let c = graph.add_node(Step::new_pick(gix::ObjectId::from_str( + )?))); + let c = graph.add_node(Some(Pick::new_pick(gix::ObjectId::from_str( "3000000000000000000000000000000000000000", - )?)); - let d = graph.add_node(Step::new_pick(gix::ObjectId::from_str( + )?))); + let d = graph.add_node(Some(Pick::new_pick(gix::ObjectId::from_str( "4000000000000000000000000000000000000000", - )?)); - let e = graph.add_node(Step::new_pick(gix::ObjectId::from_str( + )?))); + let e = graph.add_node(Some(Pick::new_pick(gix::ObjectId::from_str( "5000000000000000000000000000000000000000", - )?)); - let f = graph.add_node(Step::new_pick(gix::ObjectId::from_str( + )?))); + let f = graph.add_node(Some(Pick::new_pick(gix::ObjectId::from_str( "6000000000000000000000000000000000000000", - )?)); - let g = graph.add_node(Step::new_pick(gix::ObjectId::from_str( + )?))); + let g = graph.add_node(Some(Pick::new_pick(gix::ObjectId::from_str( "7000000000000000000000000000000000000000", - )?)); - let h = graph.add_node(Step::new_pick(gix::ObjectId::from_str( + )?))); + let h = graph.add_node(Some(Pick::new_pick(gix::ObjectId::from_str( "8000000000000000000000000000000000000000", - )?)); - let i = graph.add_node(Step::new_pick(gix::ObjectId::from_str( + )?))); + let i = graph.add_node(Some(Pick::new_pick(gix::ObjectId::from_str( "9000000000000000000000000000000000000000", - )?)); - let j = graph.add_node(Step::new_pick(gix::ObjectId::from_str( + )?))); + let j = graph.add_node(Some(Pick::new_pick(gix::ObjectId::from_str( "1100000000000000000000000000000000000000", - )?)); + )?))); - graph.add_edge(a, b, Edge { order: 0 }); - graph.add_edge(b, c, Edge { order: 0 }); - graph.add_edge(c, d, Edge { order: 0 }); - graph.add_edge(d, e, Edge { order: 0 }); + graph.push_parent(a, b); + graph.push_parent(b, c); + graph.push_parent(c, d); + graph.push_parent(d, e); - graph.add_edge(f, g, Edge { order: 0 }); - graph.add_edge(g, c, Edge { order: 0 }); + graph.push_parent(f, g); + graph.push_parent(g, c); - graph.add_edge(h, d, Edge { order: 0 }); + graph.push_parent(h, d); - graph.add_edge(i, j, Edge { order: 0 }); + graph.push_parent(i, j); snapbox::assert_data_eq!( render_ascii_graph(&graph, |_| None), @@ -450,36 +397,36 @@ mod test { ); let ordered_from_a = order_steps_picking(&graph, &[f, h]); - assert_eq!(&ordered_from_a, &[e, d, h, c, g, f]); + assert_eq!(&ordered_from_a, &[e, d, c, h, g, f]); Ok(()) } #[test] fn merge_scenario() -> Result<()> { - let mut graph = StepGraph::new(); - let a = graph.add_node(Step::new_pick(gix::ObjectId::from_str( + let mut graph = GraphEditor::default(); + let a = graph.add_node(Some(Pick::new_pick(gix::ObjectId::from_str( "1000000000000000000000000000000000000000", - )?)); - let b = graph.add_node(Step::new_pick(gix::ObjectId::from_str( + )?))); + let b = graph.add_node(Some(Pick::new_pick(gix::ObjectId::from_str( "2000000000000000000000000000000000000000", - )?)); - let c = graph.add_node(Step::new_pick(gix::ObjectId::from_str( + )?))); + let c = graph.add_node(Some(Pick::new_pick(gix::ObjectId::from_str( "3000000000000000000000000000000000000000", - )?)); - let d = graph.add_node(Step::new_pick(gix::ObjectId::from_str( + )?))); + let d = graph.add_node(Some(Pick::new_pick(gix::ObjectId::from_str( "4000000000000000000000000000000000000000", - )?)); - let e = graph.add_node(Step::new_pick(gix::ObjectId::from_str( + )?))); + let e = graph.add_node(Some(Pick::new_pick(gix::ObjectId::from_str( "5000000000000000000000000000000000000000", - )?)); + )?))); - graph.add_edge(a, b, Edge { order: 0 }); - graph.add_edge(b, c, Edge { order: 0 }); + graph.push_parent(a, b); + graph.push_parent(b, c); - graph.add_edge(a, d, Edge { order: 1 }); - graph.add_edge(d, e, Edge { order: 0 }); - graph.add_edge(e, b, Edge { order: 0 }); + graph.push_parent(a, d); + graph.push_parent(d, e); + graph.push_parent(e, b); snapbox::assert_data_eq!( render_ascii_graph(&graph, |_| None), @@ -502,29 +449,29 @@ mod test { #[test] fn merge_flipped_scenario() -> Result<()> { - let mut graph = StepGraph::new(); - let a = graph.add_node(Step::new_pick(gix::ObjectId::from_str( + let mut graph = GraphEditor::default(); + let a = graph.add_node(Some(Pick::new_pick(gix::ObjectId::from_str( "1000000000000000000000000000000000000000", - )?)); - let b = graph.add_node(Step::new_pick(gix::ObjectId::from_str( + )?))); + let b = graph.add_node(Some(Pick::new_pick(gix::ObjectId::from_str( "2000000000000000000000000000000000000000", - )?)); - let c = graph.add_node(Step::new_pick(gix::ObjectId::from_str( + )?))); + let c = graph.add_node(Some(Pick::new_pick(gix::ObjectId::from_str( "3000000000000000000000000000000000000000", - )?)); - let d = graph.add_node(Step::new_pick(gix::ObjectId::from_str( + )?))); + let d = graph.add_node(Some(Pick::new_pick(gix::ObjectId::from_str( "4000000000000000000000000000000000000000", - )?)); - let e = graph.add_node(Step::new_pick(gix::ObjectId::from_str( + )?))); + let e = graph.add_node(Some(Pick::new_pick(gix::ObjectId::from_str( "5000000000000000000000000000000000000000", - )?)); + )?))); - graph.add_edge(a, d, Edge { order: 0 }); - graph.add_edge(d, e, Edge { order: 0 }); - graph.add_edge(e, b, Edge { order: 0 }); - graph.add_edge(b, c, Edge { order: 0 }); + graph.push_parent(a, d); + graph.push_parent(d, e); + graph.push_parent(e, b); + graph.push_parent(b, c); - graph.add_edge(a, b, Edge { order: 1 }); + graph.push_parent(a, b); snapbox::assert_data_eq!( render_ascii_graph(&graph, |_| None), diff --git a/crates/but-rebase/src/graph_rebase/selector.rs b/crates/but-rebase/src/graph_rebase/selector.rs new file mode 100644 index 00000000000..213cd0fcf71 --- /dev/null +++ b/crates/but-rebase/src/graph_rebase/selector.rs @@ -0,0 +1,210 @@ +//! How callers address steps and ranges: the selector input types. + +use anyhow::{Result, anyhow}; +use but_core::RefMetadata; + +use crate::graph_rebase::{Editor, Selector, ToCommitSelector, ToReferenceSelector, ToSelector}; + +/// Defines the start and end of a range by pointing to its parent-most and child-most entries. +#[derive(Debug, Clone)] +pub struct StepRange +where + C: ToSelector, + P: ToSelector, +{ + /// The child-most entry contained within the range being defined. + pub child: C, + /// The parent-most entry contained within the range being defined. + pub parent: P, +} + +/// A non-empty set of selectors. +#[derive(Debug, Clone)] +pub struct SomeSelectors { + selectors: Vec, +} + +impl SomeSelectors { + /// Build a selector set from any selector-convertible inputs; errors when empty. + pub fn new(selectors: impl IntoIterator) -> Result + where + T: Into, + { + let selectors: Vec = selectors.into_iter().map(Into::into).collect(); + + if selectors.is_empty() { + return Err(anyhow!("Invalid selector set: This cannot be empty")); + } + + Ok(Self { selectors }) + } + + /// Returns selectors as a slice. + pub fn as_slice(&self) -> &[AnySelector] { + &self.selectors + } +} + +/// A heterogeneous selector input. +#[derive(Debug, Clone)] +pub enum AnySelector { + /// A selector that already points into the current graph revision. + Selector(Selector), + /// A commit id that should resolve to a pick step. + Commit(gix::ObjectId), + /// A reference name that should resolve to a reference step. + Reference(gix::refs::FullName), +} + +impl ToSelector for AnySelector { + fn to_selector(&self, editor: &Editor) -> Result { + match self { + Self::Selector(selector) => selector.to_selector(editor), + Self::Commit(id) => editor.select_commit(*id), + Self::Reference(reference) => editor.select_reference(reference.as_ref()), + } + } +} + +impl From for AnySelector { + fn from(value: Selector) -> Self { + Self::Selector(value) + } +} + +impl From for AnySelector { + fn from(value: gix::ObjectId) -> Self { + Self::Commit(value) + } +} + +impl From for AnySelector { + fn from(value: gix::refs::FullName) -> Self { + Self::Reference(value) + } +} + +impl TryFrom> for SomeSelectors +where + T: Into, +{ + type Error = anyhow::Error; + + fn try_from(value: Vec) -> std::result::Result { + Self::new(value) + } +} + +/// Which children or parents an operation (currently disconnect) applies to. +#[derive(Debug, Clone, Default)] +pub enum SelectorSet { + /// Select all of the children or parents. + #[default] + All, + /// No children or parents should be selected. + None, + /// A subset of children or parents should be selected. + Some(SomeSelectors), +} + +/// A commit or reference to position an operation relative to. +#[derive(Debug, Clone)] +pub enum RelativeToRef<'a> { + /// Relative to a commit + Commit(gix::ObjectId), + /// Relative to a reference + Reference(&'a gix::refs::FullNameRef), +} + +impl ToSelector for RelativeToRef<'_> { + fn to_selector(&self, editor: &Editor) -> Result { + match self { + Self::Commit(id) => editor.select_commit(*id), + Self::Reference(reference) => editor.select_reference(reference), + } + } +} + +/// The fully-owned cousin of [`RelativeToRef`]. +#[derive(Debug, Clone)] +pub enum RelativeTo { + /// Relative to a commit. + Commit(gix::ObjectId), + /// Relative to a reference. + Reference(gix::refs::FullName), +} + +impl ToSelector for RelativeTo { + fn to_selector(&self, editor: &Editor) -> Result { + match self { + Self::Commit(commit) => editor.select_commit(*commit), + Self::Reference(reference) => editor.select_reference(reference.as_ref()), + } + } +} + +impl ToCommitSelector for gix::ObjectId { + fn to_commit_selector(&self, editor: &Editor) -> Result { + editor.select_commit(*self) + } +} + +impl ToCommitSelector for gix::Id<'_> { + fn to_commit_selector(&self, editor: &Editor) -> Result { + editor.select_commit(self.detach()) + } +} + +impl ToSelector for gix::ObjectId { + fn to_selector(&self, editor: &Editor) -> Result { + editor.select_commit(*self) + } +} + +impl ToSelector for gix::Id<'_> { + fn to_selector(&self, editor: &Editor) -> Result { + editor.select_commit(self.detach()) + } +} + +impl ToReferenceSelector for &gix::refs::FullNameRef { + fn to_reference_selector(&self, editor: &Editor) -> Result { + editor.select_reference(self) + } +} + +impl ToReferenceSelector for gix::refs::FullName { + fn to_reference_selector(&self, editor: &Editor) -> Result { + editor.select_reference(self.as_ref()) + } +} + +impl ToSelector for &gix::refs::FullNameRef { + fn to_selector(&self, editor: &Editor) -> Result { + editor.select_reference(self) + } +} + +impl ToSelector for gix::refs::FullName { + fn to_selector(&self, editor: &Editor) -> Result { + editor.select_reference(self.as_ref()) + } +} + +#[cfg(test)] +mod test { + + use super::*; + + #[test] + fn empty_selector_set_creation_fails() { + let empty_parent_set = SomeSelectors::new(Vec::::new()) + .expect_err("expected empty selector set creation to fail"); + assert!( + empty_parent_set + .to_string() + .contains("Invalid selector set: This cannot be empty"), + "unexpected error: {empty_parent_set:#}" + ); + } +} diff --git a/crates/but-rebase/src/graph_rebase/testing.rs b/crates/but-rebase/src/graph_rebase/testing.rs index 6ad57630425..7b96ea910ba 100644 --- a/crates/but-rebase/src/graph_rebase/testing.rs +++ b/crates/but-rebase/src/graph_rebase/testing.rs @@ -1,5 +1,7 @@ #![deny(missing_docs)] -//! Testing utilities +//! Testing utilities for the editor graph: the `Testing` trait's `steps_ascii` draws the +//! graph as an ASCII DAG for snapshot tests. The rest of the module (group grouping, +//! head finding, topological order) supports that rendering. use std::{ cmp::Ordering, @@ -8,16 +10,11 @@ use std::{ use anyhow::Result; use but_core::RefMetadata; -use petgraph::{ - dot::{Config, Dot}, - visit::{EdgeRef, IntoEdgeReferences}, -}; use renderdag::{Ancestor, GraphRowRenderer, Renderer as _}; -#[cfg(test)] -use crate::graph_rebase::Edge; +use crate::graph_rebase::graph_editor::{PickIndex, RefIndex}; use crate::graph_rebase::{ - Editor, Pick, Selector, Step, StepGraph, StepGraphIndex, SuccessfulRebase, workspace::Subgraph, + Editor, EditorIndex, GraphEditor, Pick, Step, SuccessfulRebase, positions, workspace::Subgraph, }; /// An extension trait that adds debugging output for graphs @@ -26,56 +23,17 @@ pub trait Testing { fn steps_ascii(&self) -> String; } -impl Testing for Editor<'_, '_, M> { +impl Testing for Editor<'_, M> { fn steps_ascii(&self) -> String { render_ascii_graph(&self.graph, |id| lookup_commit_title(&self.repo, id)) } } -impl Testing for SuccessfulRebase<'_, '_, M> { +impl Testing for SuccessfulRebase<'_, M> { fn steps_ascii(&self) -> String { render_ascii_graph(&self.graph, |id| lookup_commit_title(&self.repo, id)) } } -/// An extension trait that adds debugging output for graphs -pub trait TestingDot { - /// Creates a dot graph with labels - fn steps_dot(&self) -> String; -} - -impl TestingDot for Editor<'_, '_, M> { - fn steps_dot(&self) -> String { - self.graph.steps_dot() - } -} - -impl TestingDot for SuccessfulRebase<'_, '_, M> { - fn steps_dot(&self) -> String { - self.graph.steps_dot() - } -} - -impl TestingDot for StepGraph { - fn steps_dot(&self) -> String { - format!( - "{:?}", - Dot::with_attr_getters( - &self, - &[Config::EdgeNoLabel, Config::NodeNoLabel], - &|_, v| format!("label=\"order: {}\"", v.weight().order), - &|_, (_, step)| { - match step { - Step::Pick(Pick { id, .. }) => format!("label=\"pick: {id}\""), - Step::Reference { refname, .. } => { - format!("label=\"reference: {}\"", refname.as_bstr()) - } - Step::None => "label=\"none\"".into(), - } - }, - ) - ) - } -} /// Looks up the commit title (first line of message) for a given commit id fn lookup_commit_title(repo: &gix::Repository, id: gix::ObjectId) -> Option { @@ -122,32 +80,101 @@ fn format_step(step: &Step, title: Option) -> String { } } -/// Find head nodes (no incoming edges) -fn find_heads(graph: &StepGraph) -> Vec { - let mut has_incoming: HashSet = HashSet::new(); - for edge in graph.edge_references() { - has_incoming.insert(edge.target()); +/// The reference groups, keyed by their (pick, entering-edges) position and ordered by depth โ€” +/// the render's view of positioned refs as rows. +type GroupKey = (PickIndex, Vec<(PickIndex, usize)>); + +fn ref_groups(graph: &GraphEditor) -> HashMap> { + let mut out: HashMap<_, Vec<(usize, RefIndex)>> = HashMap::new(); + for entry in graph.positioned_refs() { + let Some(on) = graph.positioned_on(entry) else { + continue; + }; + out.entry((on, positions::edges_through(graph, entry))) + .or_default() + .push((positions::ref_depth(graph, entry), entry)); } + out.into_iter() + .map(|(key, mut members)| { + members.sort_by_key(|(depth, _)| *depth); + (key, members.into_iter().map(|(_, entry)| entry).collect()) + }) + .collect() +} + +/// Find head rows: picks (and tombstones) without incoming edges, plus the tops of root +/// reference groups (positioned with nothing above them). +fn find_heads(graph: &GraphEditor) -> Vec { + let mut has_incoming: HashSet = HashSet::new(); + for idx in graph.node_ids() { + has_incoming.extend(graph.parents(idx)); + } + let groups = ref_groups(graph); + // Row-arena entries first, then references (the render sorts heads deterministically, so + // seed order does not reach snapshots): a pick or tombstone with no incoming edges, or + // the top of a root reference group. graph - .node_indices() - .filter(|idx| !has_incoming.contains(idx)) + .node_ids() + .map(EditorIndex::from) + .chain(graph.ref_indices().map(EditorIndex::from)) + .filter(|idx| match graph.positioned_on(*idx) { + Some(on) => { + let entering = positions::edges_through(graph, *idx); + entering.is_empty() + && groups + .get(&(on, entering)) + .and_then(|members| members.last()) + .map(|&m| EditorIndex::from(m)) + == Some(*idx) + } + // Positionless entries (picks, tombstones, and hand-built reference entries that never + // went through creation's finalize pass) keep the edge-era rule. + None => !idx.as_pick().is_some_and(|n| has_incoming.contains(&n)), + }) .collect() } -/// Get parents sorted by edge order -fn get_sorted_parents(graph: &StepGraph, node: StepGraphIndex) -> Vec { - let mut parents: Vec<_> = graph - .edges(node) - .map(|e| (e.weight().order, e.target())) - .collect(); - parents.sort_by_key(|(order, _)| *order); - parents.into_iter().map(|(_, p)| p).collect() +/// Rendered parents of `entry`, in order: a reference row points at the next group member +/// below it (or its pick); a pick's parent edges route through the group positioned on +/// that (parent, parent number), when one exists โ€” reproducing the interposed rows references had +/// when they were entries. +fn rendered_parents(graph: &GraphEditor, entry: EditorIndex) -> Vec { + let groups = ref_groups(graph); + if let Some(on) = graph.positioned_on(entry) { + let group = groups + .get(&(on, positions::edges_through(graph, entry))) + .map(Vec::as_slice) + .unwrap_or_default(); + let below = group + .iter() + .position(|&n| EditorIndex::from(n) == entry) + .and_then(|ix| ix.checked_sub(1)) + .map(|ix| EditorIndex::from(group[ix])) + .unwrap_or(on.into()); + return vec![below]; + } + graph + .parents(entry) + .iter() + .copied() + .enumerate() + .map(|(order, target)| { + let entry_node = entry.as_pick(); + groups + .iter() + .find(|((pick, entering), _)| { + *pick == target && entry_node.is_some_and(|n| entering.contains(&(n, order))) + }) + .and_then(|(_, group)| group.last().copied().map(EditorIndex::from)) + .unwrap_or(target.into()) + }) + .collect() } -/// A deterministic ordering for the head nodes so snapshots are stable: picks +/// A deterministic ordering for the head entries so snapshots are stable: picks /// before references, then by id / refname. -fn compare_heads(graph: &StepGraph, a: StepGraphIndex, b: StepGraphIndex) -> Ordering { - match (&graph[a], &graph[b]) { +fn compare_heads(graph: &GraphEditor, a: EditorIndex, b: EditorIndex) -> Ordering { + match (&graph.step_view(a), &graph.step_view(b)) { ( Step::Reference { refname, .. }, Step::Reference { @@ -163,20 +190,20 @@ fn compare_heads(graph: &StepGraph, a: StepGraphIndex, b: StepGraphIndex) -> Ord } } -/// Children-first topological order over `nodes`, seeded from `heads`. +/// Children-first topological order over `entries`, seeded from `heads`. /// -/// Only edges between nodes in `nodes` are followed, so this works for a full -/// graph (where `nodes` is every index) as well as a subgraph that doesn't +/// Only edges between entries in `entries` are followed, so this works for a full +/// graph (where `entries` is every index) as well as a subgraph that doesn't /// include its parents. fn topological_order( - graph: &StepGraph, - nodes: &HashSet, - heads: &[StepGraphIndex], -) -> Vec { - // Incoming edges from *within* the node set. - let mut in_degree: HashMap = nodes.iter().map(|&n| (n, 0)).collect(); - for &n in nodes { - for parent in get_sorted_parents(graph, n) { + graph: &GraphEditor, + entries: &HashSet, + heads: &[EditorIndex], +) -> Vec { + // Incoming edges from *within* the entry set. + let mut in_degree: HashMap = entries.iter().map(|&n| (n, 0)).collect(); + for &n in entries { + for parent in rendered_parents(graph, n) { if let Some(deg) = in_degree.get_mut(&parent) { *deg += 1; } @@ -184,26 +211,26 @@ fn topological_order( } let mut result = Vec::new(); - let mut visited: HashSet = HashSet::new(); + let mut visited: HashSet = HashSet::new(); fn dfs( - node: StepGraphIndex, - graph: &StepGraph, - nodes: &HashSet, - visited: &mut HashSet, - in_degree: &mut HashMap, - result: &mut Vec, + entry: EditorIndex, + graph: &GraphEditor, + entries: &HashSet, + visited: &mut HashSet, + in_degree: &mut HashMap, + result: &mut Vec, ) { - if visited.contains(&node) || in_degree.get(&node).is_some_and(|&d| d > 0) { + if visited.contains(&entry) || in_degree.get(&entry).is_some_and(|&d| d > 0) { return; } - visited.insert(node); - result.push(node); + visited.insert(entry); + result.push(entry); - let parents: Vec<_> = get_sorted_parents(graph, node) + let parents: Vec<_> = rendered_parents(graph, entry) .into_iter() - .filter(|p| nodes.contains(p)) + .filter(|p| entries.contains(p)) .collect(); for parent in &parents { if let Some(deg) = in_degree.get_mut(parent) { @@ -211,7 +238,7 @@ fn topological_order( } } for parent in parents { - dfs(parent, graph, nodes, visited, in_degree, result); + dfs(parent, graph, entries, visited, in_degree, result); } } @@ -219,7 +246,7 @@ fn topological_order( dfs( head, graph, - nodes, + entries, &mut visited, &mut in_degree, &mut result, @@ -232,66 +259,95 @@ fn topological_order( /// Render a (sub)graph of steps as a box-drawing DAG (ร  la `git log --graph`) /// using `sapling-renderdag`. /// -/// `nodes` is the set of steps to draw and `heads` are the tips to seed the -/// ordering from; parents outside `nodes` are simply dropped, so this renders +/// `entries` is the set of steps to draw and `heads` are the tips to seed the +/// ordering from; parents outside `entries` are simply dropped, so this renders /// both full graphs and subgraphs. -fn render_step_graph( - graph: &StepGraph, - nodes: &HashSet, - heads: &[StepGraphIndex], +fn render_graph_editor( + graph: &GraphEditor, + entries: &HashSet, + heads: &[EditorIndex], mut get_title: F, ) -> String where F: FnMut(gix::ObjectId) -> Option, { let mut heads = heads.to_vec(); + // Row-view tops without a rendered child inside the subgraph โ€” e.g. reference groups + // positioned above a stack's head pick, entered only from outside โ€” are heads too. + let mut in_degree: HashMap = entries.iter().map(|&n| (n, 0)).collect(); + for &n in entries { + for parent in rendered_parents(graph, n) { + if let Some(deg) = in_degree.get_mut(&parent) { + *deg += 1; + } + } + } + let mut extra: Vec = entries + .iter() + .copied() + .filter(|n| in_degree.get(n).is_none_or(|&d| d == 0) && !heads.contains(n)) + // A positioned reference whose pick lies outside the set is a boundary group the + // edge-era walk never reached from this subgraph's heads โ€” don't seed it. + .filter(|n| { + graph.positioned_on(*n).is_none_or(|_| { + crate::graph_rebase::positions::resolve_to_pick(graph, *n) + .is_some_and(|pick| entries.contains(&pick.into())) + }) + }) + .collect(); + extra.sort_by(|a, b| compare_heads(graph, *a, *b)); + heads.retain(|h| in_degree.get(h).is_none_or(|&d| d == 0)); + heads.extend(extra); heads.sort_by(|a, b| compare_heads(graph, *a, *b)); - let mut renderer = GraphRowRenderer::::new() + let mut renderer = GraphRowRenderer::::new() .output() .with_min_row_height(1) .build_box_drawing(); let mut out = String::new(); - for node in topological_order(graph, nodes, &heads) { - let step = &graph[node]; - let title = match step { + for entry in topological_order(graph, entries, &heads) { + let step = graph.step_view(entry); + let title = match &step { Step::Pick(Pick { id, .. }) => get_title(*id), _ => None, }; - let parents = get_sorted_parents(graph, node) + let parents = rendered_parents(graph, entry) .into_iter() - .filter(|p| nodes.contains(p)) + .filter(|p| entries.contains(p)) .map(Ancestor::Parent) .collect(); out.push_str(&renderer.next_row( - node, + entry, parents, step.to_symbol().to_string(), - format_step(step, title), + format_step(&step, title), )); } out.trim_end().to_string() } -/// Render the full step graph as a box-drawing DAG. -pub(crate) fn render_ascii_graph(graph: &StepGraph, get_title: F) -> String +/// Render the full editor graph as a box-drawing DAG. +pub(crate) fn render_ascii_graph(graph: &GraphEditor, get_title: F) -> String where F: FnMut(gix::ObjectId) -> Option, { - let nodes: HashSet = graph.node_indices().collect(); + let entries: HashSet = graph + .node_ids() + .map(EditorIndex::from) + .chain(graph.ref_indices().map(EditorIndex::from)) + .collect(); let heads = find_heads(graph); - render_step_graph(graph, &nodes, &heads, get_title) + render_graph_editor(graph, &entries, &heads, get_title) } -impl Editor<'_, '_, M> { +impl Editor<'_, M> { /// Render a [`Subgraph`] (e.g. one of the parts of [`Editor::graph_workspace`]) /// as a box-drawing DAG, in the same style as [`Testing::steps_ascii`]. pub fn subgraph_ascii(&self, subgraph: &Subgraph) -> String { - let resolve = |s: &Selector| self.history.normalize_selector(*s).ok().map(|s| s.id); - let nodes: HashSet = subgraph.nodes.iter().filter_map(resolve).collect(); - let heads: Vec = subgraph.heads.iter().filter_map(resolve).collect(); - render_step_graph(&self.graph, &nodes, &heads, |id| { + let entries: HashSet = subgraph.entries.iter().map(|s| s.id).collect(); + let heads: Vec = subgraph.heads.iter().map(|s| s.id).collect(); + render_graph_editor(&self.graph, &entries, &heads, |id| { lookup_commit_title(&self.repo, id) }) } @@ -316,7 +372,7 @@ impl Editor<'_, '_, M> { let workspace_commit = ws.workspace_commit.map(|selector| Subgraph { heads: vec![selector], - nodes: [selector].into(), + entries: [selector].into(), }); sections.push(format!( "# Workspace commit\n{}", @@ -341,30 +397,45 @@ mod tests { use super::*; - fn make_pick(hex: &str) -> Step { - Step::Pick(Pick::new_pick(gix::ObjectId::from_str(hex).unwrap())) + fn make_pick(hex: &str) -> Option { + Some(Pick::new_pick(gix::ObjectId::from_str(hex).unwrap())) + } + + fn add_ref(graph: &mut GraphEditor, name: &str) -> RefIndex { + graph.add_reference( + gix::refs::FullName::try_from(format!("refs/heads/{name}")).unwrap(), + true, + ) } - fn make_ref(name: &str) -> Step { - Step::new_reference(gix::refs::FullName::try_from(format!("refs/heads/{name}")).unwrap()) + /// Add a reference POSITIONED on `on`, the way the editor's own ref creation authors + /// refs โ€” a root group of one. + fn place_ref(graph: &mut GraphEditor, name: &str, on: PickIndex) -> RefIndex { + let ix = add_ref(graph, name); + graph.set_position(ix, on, &[], false, None); + ix } - /// Helper to build a graph and add edges with order - fn add_edge(graph: &mut StepGraph, from: StepGraphIndex, to: StepGraphIndex, order: usize) { - graph.add_edge(from, to, Edge { order }); + /// Helper to append a parent edge; the stated `order` documents the intended parent + /// number and is asserted against the push (arrays make insertion order the structure). + fn add_edge(graph: &mut GraphEditor, from: PickIndex, to: PickIndex, order: usize) { + let parent_number = graph.push_parent(from, to); + assert_eq!( + parent_number, order, + "test builder must push parents in parent_number order" + ); } #[test] fn linear_graph() { - // Simple linear: A -> B -> C -> D - let mut graph = StepGraph::new(); - let a = graph.add_node(make_ref("main")); + // Simple linear: main on B -> C -> D + let mut graph = GraphEditor::default(); let b = graph.add_node(make_pick("1111111111111111111111111111111111111111")); let c = graph.add_node(make_pick("2222222222222222222222222222222222222222")); let d = graph.add_node(make_pick("3333333333333333333333333333333333333333")); - let none = graph.add_node(Step::None); + let none = graph.add_node(None); + place_ref(&mut graph, "main", b); - add_edge(&mut graph, a, b, 0); add_edge(&mut graph, b, c, 0); add_edge(&mut graph, c, d, 0); add_edge(&mut graph, d, none, 0); @@ -390,11 +461,12 @@ mod tests { // A B // \ / // C - let mut graph = StepGraph::new(); - let m = graph.add_node(make_ref("main")); + let mut graph = GraphEditor::default(); + let m = graph.add_node(make_pick("9999999999999999999999999999999999999999")); let a = graph.add_node(make_pick("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); let b = graph.add_node(make_pick("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")); let c = graph.add_node(make_pick("cccccccccccccccccccccccccccccccccccccccc")); + place_ref(&mut graph, "main", m); // M has two parents: A (first) and B (second) add_edge(&mut graph, m, a, 0); @@ -407,7 +479,8 @@ mod tests { snapbox::assert_data_eq!( output, snapbox::str![[r#" -โ—Ž refs/heads/main +โ—Ž refs/heads/main +โ— 9999999 โ”œโ”€โ•ฎ โ— โ”‚ aaaaaaa โ”‚ โ— bbbbbbb @@ -425,12 +498,13 @@ mod tests { // A B C // \ | / // D - let mut graph = StepGraph::new(); - let m = graph.add_node(make_ref("main")); + let mut graph = GraphEditor::default(); + let m = graph.add_node(make_pick("9999999999999999999999999999999999999999")); let a = graph.add_node(make_pick("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); let b = graph.add_node(make_pick("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")); let c = graph.add_node(make_pick("cccccccccccccccccccccccccccccccccccccccc")); let d = graph.add_node(make_pick("dddddddddddddddddddddddddddddddddddddddd")); + place_ref(&mut graph, "main", m); // M has three parents add_edge(&mut graph, m, a, 0); @@ -445,7 +519,8 @@ mod tests { snapbox::assert_data_eq!( output, snapbox::str![[r#" -โ—Ž refs/heads/main +โ—Ž refs/heads/main +โ— 9999999 โ”œโ”€โ”ฌโ”€โ•ฎ โ— โ”‚ โ”‚ aaaaaaa โ”‚ โ— โ”‚ bbbbbbb @@ -467,14 +542,15 @@ mod tests { // X Y Z \ // \ | / | // C-----+ - let mut graph = StepGraph::new(); - let m = graph.add_node(make_ref("main")); + let mut graph = GraphEditor::default(); + let m = graph.add_node(make_pick("9999999999999999999999999999999999999999")); let f = graph.add_node(make_pick("ffffffffffffffffffffffffffffffffffffffff")); // fork point let b = graph.add_node(make_pick("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")); let x = graph.add_node(make_pick("1111111111111111111111111111111111111111")); let y = graph.add_node(make_pick("2222222222222222222222222222222222222222")); let z = graph.add_node(make_pick("3333333333333333333333333333333333333333")); let c = graph.add_node(make_pick("cccccccccccccccccccccccccccccccccccccccc")); + place_ref(&mut graph, "main", m); // M has two parents: F (first) and B (second) add_edge(&mut graph, m, f, 0); @@ -497,7 +573,8 @@ mod tests { snapbox::assert_data_eq!( output, snapbox::str![[r#" -โ—Ž refs/heads/main +โ—Ž refs/heads/main +โ— 9999999 โ”œโ”€โ•ฎ โ— โ”‚ fffffff โ”œโ”€โ”€โ”€โ”ฌโ”€โ•ฎ @@ -516,13 +593,14 @@ mod tests { #[test] fn four_way_merge() { // Four-way merge - let mut graph = StepGraph::new(); - let m = graph.add_node(make_ref("main")); + let mut graph = GraphEditor::default(); + let m = graph.add_node(make_pick("9999999999999999999999999999999999999999")); let a = graph.add_node(make_pick("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); let b = graph.add_node(make_pick("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")); let c = graph.add_node(make_pick("cccccccccccccccccccccccccccccccccccccccc")); let d = graph.add_node(make_pick("dddddddddddddddddddddddddddddddddddddddd")); let base = graph.add_node(make_pick("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee")); + place_ref(&mut graph, "main", m); add_edge(&mut graph, m, a, 0); add_edge(&mut graph, m, b, 1); @@ -538,7 +616,8 @@ mod tests { snapbox::assert_data_eq!( output, snapbox::str![[r#" -โ—Ž refs/heads/main +โ—Ž refs/heads/main +โ— 9999999 โ”œโ”€โ”ฌโ”€โ”ฌโ”€โ•ฎ โ— โ”‚ โ”‚ โ”‚ aaaaaaa โ”‚ โ— โ”‚ โ”‚ bbbbbbb @@ -564,13 +643,14 @@ mod tests { // A3 | // \ / // C - let mut graph = StepGraph::new(); - let m = graph.add_node(make_ref("main")); + let mut graph = GraphEditor::default(); + let m = graph.add_node(make_pick("9999999999999999999999999999999999999999")); let a1 = graph.add_node(make_pick("a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1")); let a2 = graph.add_node(make_pick("a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2")); let a3 = graph.add_node(make_pick("a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3")); let b = graph.add_node(make_pick("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")); let c = graph.add_node(make_pick("cccccccccccccccccccccccccccccccccccccccc")); + place_ref(&mut graph, "main", m); add_edge(&mut graph, m, a1, 0); add_edge(&mut graph, m, b, 1); @@ -583,7 +663,8 @@ mod tests { snapbox::assert_data_eq!( output, snapbox::str![[r#" -โ—Ž refs/heads/main +โ—Ž refs/heads/main +โ— 9999999 โ”œโ”€โ•ฎ โ— โ”‚ a1a1a1a โ— โ”‚ a2a2a2a @@ -605,13 +686,14 @@ mod tests { // D E | // \ / | // F-----+ - let mut graph = StepGraph::new(); - let a = graph.add_node(make_ref("main")); + let mut graph = GraphEditor::default(); + let a = graph.add_node(make_pick("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); let b = graph.add_node(make_pick("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")); let c = graph.add_node(make_pick("cccccccccccccccccccccccccccccccccccccccc")); let d = graph.add_node(make_pick("dddddddddddddddddddddddddddddddddddddddd")); let e = graph.add_node(make_pick("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee")); let f = graph.add_node(make_pick("ffffffffffffffffffffffffffffffffffffffff")); + place_ref(&mut graph, "main", a); // A forks to B, C add_edge(&mut graph, a, b, 0); @@ -630,7 +712,8 @@ mod tests { snapbox::assert_data_eq!( output, snapbox::str![[r#" -โ—Ž refs/heads/main +โ—Ž refs/heads/main +โ— aaaaaaa โ”œโ”€โ•ฎ โ— โ”‚ bbbbbbb โ”œโ”€โ”€โ”€โ•ฎ @@ -655,8 +738,8 @@ mod tests { // X Y Z \| // \|/ | // D-----+ - let mut graph = StepGraph::new(); - let m = graph.add_node(make_ref("main")); + let mut graph = GraphEditor::default(); + let m = graph.add_node(make_pick("9999999999999999999999999999999999999999")); let f = graph.add_node(make_pick("ffffffffffffffffffffffffffffffffffffffff")); let b = graph.add_node(make_pick("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")); let c = graph.add_node(make_pick("cccccccccccccccccccccccccccccccccccccccc")); @@ -664,6 +747,7 @@ mod tests { let y = graph.add_node(make_pick("2222222222222222222222222222222222222222")); let z = graph.add_node(make_pick("3333333333333333333333333333333333333333")); let d = graph.add_node(make_pick("dddddddddddddddddddddddddddddddddddddddd")); + place_ref(&mut graph, "main", m); // M forks to F, B, C add_edge(&mut graph, m, f, 0); @@ -686,7 +770,8 @@ mod tests { snapbox::assert_data_eq!( output, snapbox::str![[r#" -โ—Ž refs/heads/main +โ—Ž refs/heads/main +โ— 9999999 โ”œโ”€โ”ฌโ”€โ•ฎ โ— โ”‚ โ”‚ fffffff โ”œโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ•ฎ @@ -718,8 +803,8 @@ mod tests { // E F <- D forks to E and F, F is shared with C // \ / // base - let mut graph = StepGraph::new(); - let m = graph.add_node(make_ref("main")); + let mut graph = GraphEditor::default(); + let m = graph.add_node(make_pick("9999999999999999999999999999999999999999")); let a = graph.add_node(make_pick("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); let b = graph.add_node(make_pick("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")); let c = graph.add_node(make_pick("cccccccccccccccccccccccccccccccccccccccc")); @@ -727,6 +812,7 @@ mod tests { let e = graph.add_node(make_pick("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee")); let f = graph.add_node(make_pick("ffffffffffffffffffffffffffffffffffffffff")); let base = graph.add_node(make_pick("0000000000000000000000000000000000000000")); + place_ref(&mut graph, "main", m); // M forks to A, B, C add_edge(&mut graph, m, a, 0); @@ -752,7 +838,8 @@ mod tests { snapbox::assert_data_eq!( output, snapbox::str![[r#" -โ—Ž refs/heads/main +โ—Ž refs/heads/main +โ— 9999999 โ”œโ”€โ”ฌโ”€โ•ฎ โ— โ”‚ โ”‚ aaaaaaa โ— โ”‚ โ”‚ ddddddd @@ -786,8 +873,8 @@ mod tests { // | G <- B, C, and D's second branch merge at G // \ / // F <- E and G merge at F - let mut graph = StepGraph::new(); - let m = graph.add_node(make_ref("main")); + let mut graph = GraphEditor::default(); + let m = graph.add_node(make_pick("1111111111111111111111111111111111111111")); let a = graph.add_node(make_pick("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); let b = graph.add_node(make_pick("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")); let c = graph.add_node(make_pick("cccccccccccccccccccccccccccccccccccccccc")); @@ -795,6 +882,7 @@ mod tests { let e = graph.add_node(make_pick("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee")); let g = graph.add_node(make_pick("9999999999999999999999999999999999999999")); let f = graph.add_node(make_pick("ffffffffffffffffffffffffffffffffffffffff")); + place_ref(&mut graph, "main", m); // M forks to A, B, C add_edge(&mut graph, m, a, 0); @@ -820,7 +908,8 @@ mod tests { snapbox::assert_data_eq!( output, snapbox::str![[r#" -โ—Ž refs/heads/main +โ—Ž refs/heads/main +โ— 1111111 โ”œโ”€โ”ฌโ”€โ•ฎ โ— โ”‚ โ”‚ aaaaaaa โ— โ”‚ โ”‚ ddddddd @@ -851,8 +940,8 @@ mod tests { // E F shared <- D forks to E, F, shared where shared comes from C // \|/ // base - let mut graph = StepGraph::new(); - let m = graph.add_node(make_ref("main")); + let mut graph = GraphEditor::default(); + let m = graph.add_node(make_pick("9999999999999999999999999999999999999999")); let a = graph.add_node(make_pick("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); let b = graph.add_node(make_pick("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")); let c = graph.add_node(make_pick("cccccccccccccccccccccccccccccccccccccccc")); @@ -861,6 +950,7 @@ mod tests { let f = graph.add_node(make_pick("ffffffffffffffffffffffffffffffffffffffff")); let shared = graph.add_node(make_pick("1111111111111111111111111111111111111111")); let base = graph.add_node(make_pick("0000000000000000000000000000000000000000")); + place_ref(&mut graph, "main", m); // M forks to A, B, C add_edge(&mut graph, m, a, 0); @@ -887,7 +977,8 @@ mod tests { snapbox::assert_data_eq!( output, snapbox::str![[r#" -โ—Ž refs/heads/main +โ—Ž refs/heads/main +โ— 9999999 โ”œโ”€โ”ฌโ”€โ•ฎ โ— โ”‚ โ”‚ aaaaaaa โ— โ”‚ โ”‚ ddddddd @@ -907,21 +998,20 @@ mod tests { #[test] fn subgraph_drops_parents_outside_the_node_set() { - // main -> a -> b -> base, rendering only the subgraph {a, b}. - // `main` (a child of `a`) and `base` (a parent of `b`) are outside the - // set, so neither is drawn and `b` renders as a root. - let mut graph = StepGraph::new(); - let main = graph.add_node(make_ref("main")); + // main on a -> b -> base, rendering only the subgraph {a, b}. + // `main` (positioned on `a`) and `base` (a parent of `b`) are outside + // the set, so neither is drawn and `b` renders as a root. + let mut graph = GraphEditor::default(); let a = graph.add_node(make_pick("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); let b = graph.add_node(make_pick("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")); let base = graph.add_node(make_pick("0000000000000000000000000000000000000000")); + place_ref(&mut graph, "main", a); - add_edge(&mut graph, main, a, 0); add_edge(&mut graph, a, b, 0); add_edge(&mut graph, b, base, 0); - let nodes: HashSet = [a, b].into_iter().collect(); - let output = render_step_graph(&graph, &nodes, &[a], |_| None); + let entries: HashSet = [a.into(), b.into()].into_iter().collect(); + let output = render_graph_editor(&graph, &entries, &[a.into()], |_| None); snapbox::assert_data_eq!( output, snapbox::str![[r#" diff --git a/crates/but-rebase/src/graph_rebase/traverse.rs b/crates/but-rebase/src/graph_rebase/traverse.rs index 5a8d1ba3f7d..536092697c6 100644 --- a/crates/but-rebase/src/graph_rebase/traverse.rs +++ b/crates/but-rebase/src/graph_rebase/traverse.rs @@ -1,16 +1,17 @@ -//! Step graph traversal helpers. +//! Commit graph traversal helpers. use std::collections::HashSet; use anyhow::Result; use but_core::RefMetadata; -use petgraph::{Direction, visit::EdgeRef as _}; -use crate::graph_rebase::{Editor, Selector, Step, StepGraph, StepGraphIndex, ToSelector}; +use crate::graph_rebase::graph_editor::PickIndex; +use crate::graph_rebase::mutate::node_entry; +use crate::graph_rebase::{Editor, EditorIndex, GraphEditor, Selector, ToSelector, positions}; /// How far `a` is ahead of and behind `b`, counted in commits. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct AheadBehind { +pub(crate) struct AheadBehind { /// Commits reachable from `a` but not `b` (the rev-set `a ^b`). pub ahead: usize, /// Commits reachable from `b` but not `a` (the rev-set `b ^a`). @@ -18,25 +19,19 @@ pub struct AheadBehind { } /// Count the `Pick` steps (i.e. commits) among `steps`. -fn count_picks(graph: &StepGraph, steps: impl Iterator) -> usize { - steps - .filter(|ix| matches!(graph[*ix], Step::Pick(_))) - .count() +fn count_picks(graph: &GraphEditor, steps: impl Iterator) -> usize { + steps.filter(|ix| graph.is_pick(*ix)).count() } struct Traversal<'graph> { - graph: &'graph StepGraph, - excluded: HashSet, - seen: HashSet, - tips: Vec, + graph: &'graph GraphEditor, + excluded: HashSet, + seen: HashSet, + tips: Vec, } impl<'graph> Traversal<'graph> { - fn new( - graph: &'graph StepGraph, - start: StepGraphIndex, - excluded: HashSet, - ) -> Self { + fn new(graph: &'graph GraphEditor, start: EditorIndex, excluded: HashSet) -> Self { Self { graph, excluded, @@ -47,18 +42,15 @@ impl<'graph> Traversal<'graph> { } impl Iterator for Traversal<'_> { - type Item = StepGraphIndex; + type Item = EditorIndex; fn next(&mut self) -> Option { while let Some(n) = self.tips.pop() { if self.excluded.contains(&n) || !self.seen.insert(n) { continue; } - self.tips.extend( - self.graph - .edges_directed(n, Direction::Outgoing) - .map(|e| e.target()), - ); + self.tips + .extend(self.graph.parents(n).into_iter().map(EditorIndex::from)); return Some(n); } None @@ -67,64 +59,227 @@ impl Iterator for Traversal<'_> { /// Every step reachable from `start` following parent edges (`Outgoing`). pub(crate) fn reachable_from( - graph: &StepGraph, - start: StepGraphIndex, -) -> impl Iterator + '_ { + graph: &GraphEditor, + start: EditorIndex, +) -> impl Iterator + '_ { Traversal::new(graph, start, HashSet::new()) } /// The rev-set `start ^excluded`: steps reachable from `start` but not /// `excluded`. pub(crate) fn a_not_b( - graph: &StepGraph, - start: StepGraphIndex, - excluded: StepGraphIndex, -) -> impl Iterator + '_ { - let excluded = reachable_from(graph, excluded).collect(); - Traversal::new(graph, start, excluded) + graph: &GraphEditor, + start: EditorIndex, + excluded: EditorIndex, +) -> impl Iterator + '_ { + all_until_optional_limit(graph, start, Some(excluded)) } /// All steps in `start ^limit`, or everything reachable from `start` when there /// is no `limit`. pub(crate) fn all_until_optional_limit( - graph: &StepGraph, - start: StepGraphIndex, - limit: Option, -) -> impl Iterator + '_ { + graph: &GraphEditor, + start: EditorIndex, + limit: Option, +) -> impl Iterator + '_ { let excluded = limit .map(|limit| reachable_from(graph, limit).collect()) .unwrap_or_default(); Traversal::new(graph, start, excluded) } -impl Editor<'_, '_, M> { +impl Editor<'_, M> { + /// Returns all direct children of `target` together with their edge order. + /// + /// Children are represented as incoming edges into `target` in the editor graph. + pub fn direct_children(&self, target: impl ToSelector) -> Result> { + let target = target.to_selector(self)?; + // A reference's children are the edges entering through its position. + if self.graph.is_positioned(target.id) { + return Ok(positions::edges_through(&self.graph, target.id) + .into_iter() + .map(|(child, parent_number)| (self.new_selector(child), parent_number)) + .collect()); + } + Ok(self + .graph + .incoming_edges(target.id) + .iter() + .map(|&(child, parent_number)| (self.new_selector(child), parent_number)) + .collect()) + } + + /// Returns all direct parents of `target` together with their edge order. + /// + /// Parents are represented as outgoing edges from `target` in the editor graph. + pub fn direct_parents(&self, target: impl ToSelector) -> Result> { + let target = target.to_selector(self)?; + // A reference's one downward link is its pick. + if let Some(on) = self.graph.positioned_on(target.id) { + let pick = self.resolved_pick(on)?; + return Ok(vec![(self.new_selector(pick), 0)]); + } + Ok(self + .graph + .parents(target.id) + .iter() + .copied() + .enumerate() + .map(|(parent_number, parent)| (self.new_selector(parent), parent_number)) + .collect()) + } + + /// The interposed parent view of `target`: reference groups sit between a commit and + /// its parent, so the view shows the branch names a walk would pass through. For a pick, + /// each parent edge resolves to the top of the group it carries (falling back to the + /// pick); for a reference, the next group member below, then the pick. Useful for + /// renderers that interleave references with commits. + pub fn position_parents(&self, target: impl ToSelector) -> Result> { + let target = target.to_selector(self)?; + if let Some(on) = self.graph.positioned_on(target.id) { + let pick = self.resolved_pick(on)?; + // The physical member directly below is stored adjacency; the pick when at + // the bottom of the stack. + return Ok(vec![match self.graph.below_of(target.id) { + Some(below) => self.new_selector(below), + None => self.new_selector(pick), + }]); + } + Ok(self + .graph + .parents(target.id) + .iter() + .copied() + .enumerate() + .map(|(parent_number, pick)| { + let carried_top = self + .graph + .positioned_refs() + .filter(|&entry| { + let Some(node) = target.id.as_pick() else { + return false; + }; + positions::edges_through(&self.graph, entry) + .contains(&(node, parent_number)) + && positions::resolve_to_pick(&self.graph, entry) == Some(pick) + }) + .max_by_key(|&entry| (positions::ref_depth(&self.graph, entry), entry)); + match carried_top { + Some(top) => self.new_selector(top), + None => self.new_selector(pick), + } + }) + .collect()) + } + + /// The interposed child view of `target` โ€” the inverse of [`Self::position_parents`]. + /// + /// For a pick, its children are the bottom members of the groups sitting on it plus the + /// plain edges into it; for a reference, the next group member above, else its edges. + pub fn position_children(&self, target: impl ToSelector) -> Result> { + let target = target.to_selector(self)?; + if self.graph.is_positioned(target.id) { + let pick = positions::resolve_to_pick(&self.graph, target.id); + // Members sitting directly on it (group-mates and root siblings stacked above), + // plus โ€” when this is the top of its group โ€” the edges that enter it. + let mut out: Vec = self + .graph + .positioned_refs() + .filter(|&entry| { + EditorIndex::from(entry) != target.id + && self.graph.below_of(entry).map(EditorIndex::from) == Some(target.id) + }) + .map(|entry| self.new_selector(entry)) + .collect(); + let target_edges = positions::edges_through(&self.graph, target.id); + let target_depth = positions::ref_depth(&self.graph, target.id); + let is_group_top = !self.graph.positioned_refs().any(|entry| { + EditorIndex::from(entry) != target.id + && positions::edges_through(&self.graph, entry) == target_edges + && positions::ref_depth(&self.graph, entry) > target_depth + && positions::resolve_to_pick(&self.graph, entry) == pick + }); + if is_group_top { + out.extend( + target_edges + .iter() + .map(|(edge, _)| self.new_selector(*edge)), + ); + } + out.sort_by_key(|s| s.id); + out.dedup_by_key(|s| s.id); + return Ok(out); + } + // Bottom members sit directly on the pick; other edges are plain. + let mut out: Vec = self + .graph + .positioned_refs() + .filter(|&entry| { + self.graph.below_of(entry).is_none() + && positions::resolve_to_pick(&self.graph, entry).map(EditorIndex::from) + == Some(target.id) + }) + .map(|entry| self.new_selector(entry)) + .collect(); + for &(child, parent_number) in self.graph.incoming_edges(target.id) { + let carrying = self.graph.positioned_refs().any(|entry| { + positions::edges_through(&self.graph, entry).contains(&(child, parent_number)) + && positions::resolve_to_pick(&self.graph, entry).map(EditorIndex::from) + == Some(target.id) + }); + if !carrying { + out.push(self.new_selector(child)); + } + } + out.sort_by_key(|s| s.id); + out.dedup_by_key(|s| s.id); + Ok(out) + } + + /// For a given step, find all the references that point to it. + /// + /// The reference selectors are provided in no particular order. + pub fn step_references(&self, target: impl ToSelector) -> Result> { + let target = target.to_selector(self)?; + + let node = node_entry(target.id)?; + Ok( + crate::graph_rebase::positions::refs_resolving_to(&self.graph, node) + .into_iter() + .map(|entry| self.new_selector(entry)) + .collect(), + ) + } + /// Every selector reachable from `start` following parent edges. - pub fn reachable_from( + pub(crate) fn reachable_from( &self, start: impl ToSelector, ) -> Result + '_> { - let start = self - .history - .normalize_selector(start.to_selector(self)?)? - .id; - Ok(reachable_from(&self.graph, start).map(|id| self.new_selector(id))) + let start = start.to_selector(self)?.id; + Ok(self + .reachable_ids(start) + .into_iter() + .map(|id| self.new_selector(id))) } - /// The rev-set `start ^excluded`, yielding selectors. - pub fn a_not_b( - &self, - start: impl ToSelector, - excluded: impl ToSelector, - ) -> Result + '_> { - let start = self - .history - .normalize_selector(start.to_selector(self)?)? - .id; - let excluded = self - .history - .normalize_selector(excluded.to_selector(self)?)? - .id; - Ok(a_not_b(&self.graph, start, excluded).map(|id| self.new_selector(id))) + /// Reachability including references: picks and tombstones by edges (a reference start + /// descends from its pick), plus every reference group the walk entered. + fn reachable_ids(&self, start: EditorIndex) -> Vec { + let seed = crate::graph_rebase::positions::resolve_to_pick(&self.graph, start); + let picks: std::collections::HashSet = match seed { + Some(seed) => reachable_from(&self.graph, seed.into()) + .filter_map(|entry| entry.as_pick()) + .collect(), + None => Default::default(), + }; + let mut all: Vec = picks.iter().copied().map(EditorIndex::from).collect(); + all.extend( + crate::graph_rebase::positions::refs_reachable_with(&self.graph, start, &picks) + .into_iter() + .map(EditorIndex::from), + ); + all } /// How far `a` is ahead of and behind `b`, counted in commits. @@ -137,34 +292,46 @@ impl Editor<'_, '_, M> { /// push fast-forwards iff the remote tip is reachable from the local tip) โ€” /// rather than the first-parent-only branch-line reasoning of /// `but_workspace`'s `derive_push_status_from_graph`. - pub fn ahead_behind(&self, a: impl ToSelector, b: impl ToSelector) -> Result { - let a = self.history.normalize_selector(a.to_selector(self)?)?.id; - let b = self.history.normalize_selector(b.to_selector(self)?)?.id; + pub(crate) fn ahead_behind( + &self, + a: impl ToSelector, + b: impl ToSelector, + ) -> Result { + let a = a.to_selector(self)?.id; + let b = b.to_selector(self)?.id; + // Only picks count, so reference endpoints stand for their picks. + let a = crate::graph_rebase::positions::resolve_to_pick(&self.graph, a); + let b = crate::graph_rebase::positions::resolve_to_pick(&self.graph, b); + let (Some(a), Some(b)) = (a, b) else { + return Ok(AheadBehind { + ahead: 0, + behind: 0, + }); + }; Ok(AheadBehind { - ahead: count_picks(&self.graph, a_not_b(&self.graph, a, b)), - behind: count_picks(&self.graph, a_not_b(&self.graph, b, a)), + ahead: count_picks(&self.graph, a_not_b(&self.graph, a.into(), b.into())), + behind: count_picks(&self.graph, a_not_b(&self.graph, b.into(), a.into())), }) } /// All selectors in `start ^limit`, or everything reachable from `start` /// when there is no `limit`. - pub fn all_until_optional_limit( + pub(crate) fn all_until_optional_limit( &self, start: impl ToSelector, limit: Option, ) -> Result + '_> { - let start = self - .history - .normalize_selector(start.to_selector(self)?)? - .id; - let limit = limit - .map(|limit| { - self.history - .normalize_selector(limit) - .map(|selector| selector.id) - }) - .transpose()?; - Ok(all_until_optional_limit(&self.graph, start, limit).map(|id| self.new_selector(id))) + let start = start.to_selector(self)?.id; + let limit = limit.map(|limit| limit.id); + let excluded: std::collections::HashSet = limit + .map(|limit| self.reachable_ids(limit).into_iter().collect()) + .unwrap_or_default(); + let result: Vec = self + .reachable_ids(start) + .into_iter() + .filter(|id| !excluded.contains(id)) + .collect(); + Ok(result.into_iter().map(|id| self.new_selector(id))) } } @@ -173,37 +340,38 @@ mod test { use std::{collections::HashSet, str::FromStr as _}; use super::{a_not_b, all_until_optional_limit, count_picks, reachable_from}; - use crate::graph_rebase::{Edge, Step, StepGraph, StepGraphIndex}; + use crate::graph_rebase::graph_editor::PickIndex; + use crate::graph_rebase::{GraphEditor, Pick}; - fn pick(graph: &mut StepGraph) -> StepGraphIndex { + fn pick(graph: &mut GraphEditor) -> PickIndex { let id = gix::ObjectId::from_str("1000000000000000000000000000000000000000").unwrap(); - graph.add_node(Step::new_pick(id)) + graph.add_node(Some(Pick::new_pick(id))) } /// `a -> b -> base` and `c -> base` (edges point child -> parent). /// `a ^c` must drop `base` (shared with `c`) but keep `a`, `b`. #[test] fn a_not_b_excludes_shared_ancestry() { - let mut g = StepGraph::new(); + let mut g = GraphEditor::default(); let a = pick(&mut g); let b = pick(&mut g); let base = pick(&mut g); let c = pick(&mut g); - g.add_edge(a, b, Edge { order: 0 }); - g.add_edge(b, base, Edge { order: 0 }); - g.add_edge(c, base, Edge { order: 0 }); + g.push_parent(a, b); + g.push_parent(b, base); + g.push_parent(c, base); assert_eq!( - a_not_b(&g, a, c).collect::>(), - [a, b].into_iter().collect() + a_not_b(&g, a.into(), c.into()).collect::>(), + [a.into(), b.into()].into_iter().collect() ); assert_eq!( - reachable_from(&g, a).collect::>(), - [a, b, base].into_iter().collect() + reachable_from(&g, a.into()).collect::>(), + [a.into(), b.into(), base.into()].into_iter().collect() ); assert_eq!( - all_until_optional_limit(&g, a, Some(c)).collect::>(), - [a, b].into_iter().collect() + all_until_optional_limit(&g, a.into(), Some(c.into())).collect::>(), + [a.into(), b.into()].into_iter().collect() ); } @@ -213,18 +381,18 @@ mod test { /// `b`; only the two picks count. `c ^a` reaches `c`. #[test] fn count_picks_ignores_non_pick_steps() { - let mut g = StepGraph::new(); + let mut g = GraphEditor::default(); let a = pick(&mut g); - let none = g.add_node(Step::None); + let none = g.add_node(None); let b = pick(&mut g); let base = pick(&mut g); let c = pick(&mut g); - g.add_edge(a, none, Edge { order: 0 }); - g.add_edge(none, b, Edge { order: 0 }); - g.add_edge(b, base, Edge { order: 0 }); - g.add_edge(c, base, Edge { order: 0 }); + g.push_parent(a, none); + g.push_parent(none, b); + g.push_parent(b, base); + g.push_parent(c, base); - assert_eq!(count_picks(&g, a_not_b(&g, a, c)), 2); - assert_eq!(count_picks(&g, a_not_b(&g, c, a)), 1); + assert_eq!(count_picks(&g, a_not_b(&g, a.into(), c.into())), 2); + assert_eq!(count_picks(&g, a_not_b(&g, c.into(), a.into())), 1); } } diff --git a/crates/but-rebase/src/graph_rebase/util.rs b/crates/but-rebase/src/graph_rebase/util.rs index 0f5be140163..6c6b12f8e46 100644 --- a/crates/but-rebase/src/graph_rebase/util.rs +++ b/crates/but-rebase/src/graph_rebase/util.rs @@ -1,48 +1,72 @@ -//! Utilities around the step graph for internal use. +//! Utilities around the commit graph for internal use. use std::collections::HashSet; -use petgraph::visit::EdgeRef as _; +use crate::graph_rebase::graph_editor::PickIndex; +use crate::graph_rebase::{EditorIndex, GraphEditor}; -use crate::graph_rebase::{Pick, Step, StepGraph, StepGraphIndex}; - -/// Find the parents of a given node that are commit - in correct parent -/// ordering. +/// Pruned depth-first search for `target`'s commit parents in parent order, descending through +/// non-commit steps. /// -/// We do this via a pruned depth first search. +/// A parent edge that carries a reference group (the edge is a stored entry of a group +/// positioned at its pick) yields to any plain parent resolving to the same pick, and only the +/// first of several carrying edges survives โ€” when a reference path and a direct path reach +/// the same pick, that pick is listed once. Plain duplicate parents are all kept +/// (dup-parents workspace commits). pub(crate) fn collect_ordered_parents( - graph: &StepGraph, - target: StepGraphIndex, -) -> Vec { - let mut potential_parent_edges = graph - .edges_directed(target, petgraph::Direction::Outgoing) - .collect::>(); - potential_parent_edges.sort_by_key(|e| e.weight().order); - potential_parent_edges.reverse(); - - let mut seen = potential_parent_edges + graph: &GraphEditor, + target: impl Into, +) -> Vec { + let target = target.into(); + // The parent numbers whose edge is a stored edge entry of some positioned group. An edge in a + // group's share always enters the pick the group is positioned on, so matching the parent number's + // parent again is redundant โ€” one pass over the refs covers every parent number. + let carried_numbers: HashSet = graph + .positioned_refs() + .flat_map(|entry| crate::graph_rebase::positions::edges_through(graph, entry)) + .filter(|&(child, _)| EditorIndex::from(child) == target) + .map(|(_, parent_number)| parent_number) + .collect(); + let carries_group = |parent_number: usize, parent: PickIndex| { + graph.is_pick(parent) && carried_numbers.contains(&parent_number) + }; + let ordered_parents = graph.parents(target); + let plain_targets: HashSet = ordered_parents + .iter() + .enumerate() + .filter(|&(parent_number, &parent)| { + graph.is_pick(parent) && !carries_group(parent_number, parent) + }) + .map(|(_, &parent)| parent) + .collect(); + let mut emitted_carrying = HashSet::new(); + + let mut potential: Vec<(PickIndex, bool)> = ordered_parents + .iter() + .enumerate() + .rev() + .map(|(parent_number, &parent)| (parent, carries_group(parent_number, parent))) + .collect(); + let mut seen = potential .iter() - .map(|e| e.target()) - .collect::>(); + .map(|(t, _)| *t) + .collect::>(); let mut parents = vec![]; - while let Some(candidate) = potential_parent_edges.pop() { - if let Step::Pick(Pick { .. }) = graph[candidate.target()] { - parents.push(candidate.target()); + while let Some((entry, carrying)) = potential.pop() { + if graph.is_pick(entry) { + if carrying && (plain_targets.contains(&entry) || !emitted_carrying.insert(entry)) { + continue; + } + parents.push(entry); // Don't pursue the children continue; }; - let mut outgoings = graph - .edges_directed(candidate.target(), petgraph::Direction::Outgoing) - .collect::>(); - outgoings.sort_by_key(|e| e.weight().order); - outgoings.reverse(); - - for edge in outgoings { - if seen.insert(edge.target()) { - potential_parent_edges.push(edge); + for &parent in graph.parents(entry).iter().rev() { + if seen.insert(parent) { + potential.push((parent, false)); } } } @@ -57,76 +81,41 @@ mod test { use anyhow::Result; - use crate::graph_rebase::{Edge, Step, StepGraph, util::collect_ordered_parents}; + use crate::graph_rebase::{GraphEditor, Pick, util::collect_ordered_parents}; #[test] fn basic_scenario() -> Result<()> { - let mut graph = StepGraph::new(); + let mut graph = GraphEditor::default(); let a_id = gix::ObjectId::from_str("1000000000000000000000000000000000000000")?; - let a = graph.add_node(Step::new_pick(a_id)); + let a = graph.add_node(Some(Pick::new_pick(a_id))); // First parent let b_id = gix::ObjectId::from_str("1000000000000000000000000000000000000000")?; - let b = graph.add_node(Step::new_pick(b_id)); - // Second parent - is a reference - let c = graph.add_node(Step::new_reference("refs/heads/foobar".try_into()?)); - // Second parent's first child + let b = graph.add_node(Some(Pick::new_pick(b_id))); + // Second parent - is a tombstone, so it flattens to its own parents + let c = graph.add_node(None); + // Second parent's first parent let d_id = gix::ObjectId::from_str("3000000000000000000000000000000000000000")?; - let d = graph.add_node(Step::new_pick(d_id)); - // Second parent's second child + let d = graph.add_node(Some(Pick::new_pick(d_id))); + // Second parent's second parent let e_id = gix::ObjectId::from_str("4000000000000000000000000000000000000000")?; - let e = graph.add_node(Step::new_pick(e_id)); + let e = graph.add_node(Some(Pick::new_pick(e_id))); // Third parent let f_id = gix::ObjectId::from_str("5000000000000000000000000000000000000000")?; - let f = graph.add_node(Step::new_pick(f_id)); + let f = graph.add_node(Some(Pick::new_pick(f_id))); // A's parents - graph.add_edge(a, b, Edge { order: 0 }); - graph.add_edge(a, c, Edge { order: 1 }); - graph.add_edge(a, f, Edge { order: 2 }); + graph.push_parent(a, b); + graph.push_parent(a, c); + graph.push_parent(a, f); // C's parents - graph.add_edge(c, d, Edge { order: 0 }); - graph.add_edge(c, e, Edge { order: 1 }); + graph.push_parent(c, d); + graph.push_parent(c, e); let parents = collect_ordered_parents(&graph, a); assert_eq!(&parents, &[b, d, e, f]); Ok(()) } - - #[test] - fn insertion_order_is_irrelevant() -> Result<()> { - let mut graph = StepGraph::new(); - let a_id = gix::ObjectId::from_str("1000000000000000000000000000000000000000")?; - let a = graph.add_node(Step::new_pick(a_id)); - // First parent - let b_id = gix::ObjectId::from_str("1000000000000000000000000000000000000000")?; - let b = graph.add_node(Step::new_pick(b_id)); - // Second parent - is a reference - let c = graph.add_node(Step::new_reference("refs/heads/foobar".try_into()?)); - // Second parent's second child - let d_id = gix::ObjectId::from_str("3000000000000000000000000000000000000000")?; - let d = graph.add_node(Step::new_pick(d_id)); - // Second parent's first child - let e_id = gix::ObjectId::from_str("4000000000000000000000000000000000000000")?; - let e = graph.add_node(Step::new_pick(e_id)); - // Third parent - let f_id = gix::ObjectId::from_str("5000000000000000000000000000000000000000")?; - let f = graph.add_node(Step::new_pick(f_id)); - - // A's parents - graph.add_edge(a, f, Edge { order: 2 }); - graph.add_edge(a, c, Edge { order: 1 }); - graph.add_edge(a, b, Edge { order: 0 }); - - // C's parents - graph.add_edge(c, d, Edge { order: 1 }); - graph.add_edge(c, e, Edge { order: 0 }); - - let parents = collect_ordered_parents(&graph, a); - assert_eq!(&parents, &[b, e, d, f]); - - Ok(()) - } } } diff --git a/crates/but-rebase/src/graph_rebase/workspace.rs b/crates/but-rebase/src/graph_rebase/workspace.rs index 4cf4fb4e75f..0170e3b6aba 100644 --- a/crates/but-rebase/src/graph_rebase/workspace.rs +++ b/crates/but-rebase/src/graph_rebase/workspace.rs @@ -1,11 +1,13 @@ //! A graph based workspace projection, framed from the rebase [`Editor`]. //! -//! Rather than being its own graph, this points into the editor's internal step +//! Rather than being its own graph, this points into the editor's internal commit //! graph via [`Selector`]s, so consumers can frame the mutations they're about //! to perform against the same selectors they'll act on. use std::collections::{HashMap, HashSet}; +use crate::graph_rebase::graph_editor::{PickIndex, RefIndex}; +use crate::graph_rebase::positions; use anyhow::Result; use but_core::{ RefMetadata, WORKSPACE_REF_NAME, @@ -17,31 +19,30 @@ use but_core::{ }; use but_graph::workspace::commit::is_managed_workspace_by_message; use gix::prelude::ObjectIdExt; -use petgraph::{Direction, visit::EdgeRef as _}; use crate::graph_rebase::{ - Checkout, Editor, LookupStep, Pick, Selector, Step, StepGraph, StepGraphIndex, + Checkout, Editor, EditorIndex, GraphEditor, LookupStep, Pick, Selector, Step, traverse::{self, AheadBehind}, }; /// A structure that gives a frame of reference to a key subgraph in the /// workspace framing. This could be the subgraph of all commits above the -/// workspace, or the nodes that make up a "stack". +/// workspace, or the entries that make up a "stack". /// /// Rather than being a full graph structure, this provides pointers into the -/// editor's internal step graph. +/// editor's internal commit graph. pub struct Subgraph { - /// Nodes in the subgraph that only have incoming edges + /// Entries in the subgraph that only have incoming edges pub heads: Vec, - /// All the nodes in the specified subgraph - pub nodes: HashSet, + /// All the entries in the specified subgraph + pub entries: HashSet, } impl Subgraph { fn empty() -> Self { Self { heads: vec![], - nodes: HashSet::new(), + entries: HashSet::new(), } } } @@ -66,29 +67,19 @@ pub struct GraphWorkspace { /// exclusive sub-graphs of commits that don't have any incoming or outgoing /// edges to other commits in other stacks. /// + /// Membership is computed over COMMITS: references are positions, not topology, so a + /// shared ref entry (typically the target's, sitting above an excluded target commit) + /// cannot glue two distinct stacks together. Each reference joins the stack its position + /// belongs to โ€” its entering child's, else its pick's โ€” and a group hanging + /// straight off the workspace commit keeps its own stack even without commits (an empty + /// branch). A reference whose position lies outside every stack (e.g. the target's own + /// ref) is in none of them. + /// /// As a natural extension, if we failed to find the workspace commit, this /// list will be empty since all the commits will deemed "above_workspace". /// /// If we're outside of the workspace branch, there will be one stack that /// contains all commits in the rev-set `HEAD ^target_sha`. - /// - /// # Known limitation: stacks sharing a target segment collapse into one - /// - /// Today, stacks that converge on a shared segment - most importantly the - /// target (`origin/main`) segment every real workspace stack sits on - get - /// merged into a single stack instead of staying separate. This is a - /// consequence of how the editor's step graph is built, *not* of the rebase - /// topology, so a fixture can look like N obviously-distinct stacks and - /// still come back as one. - /// - /// The segment's head reference becomes its first node (see `Editor::create` - /// in `creation.rs`), and each child stack attaches to that node. So when - /// two stacks share the target segment, they both point at its ref node and - /// the split treats them as one. A target doesn't help: it excludes the - /// target *commit*, but the ref node sits above that commit and survives. - /// - /// In this scenario, the but graph really ought to be providing a graph - /// that doesn't let us put the node there. pub stacks: Vec, /// Per-reference push and integration status for every local-branch @@ -125,10 +116,19 @@ struct Integration { impl GraphWorkspace { fn empty() -> Self { + Self::topology(Subgraph::empty(), None, vec![]) + } + + /// A projection skeleton with empty status maps; they are filled in later. + fn topology( + above_workspace: Subgraph, + workspace_commit: Option, + stacks: Vec, + ) -> Self { Self { - above_workspace: Subgraph::empty(), - workspace_commit: None, - stacks: vec![], + above_workspace, + workspace_commit, + stacks, reference_status: HashMap::new(), commit_state: HashMap::new(), } @@ -136,48 +136,38 @@ impl GraphWorkspace { } /// The index-level analog of [`Subgraph`], used internally so the traversal and -/// set-algebra stay on cheap `StepGraphIndex`es; converted to selectors once at +/// set-algebra stay on cheap `EditorIndex`es; converted to selectors once at /// the boundary. struct NodeSet { - heads: Vec, - nodes: HashSet, + heads: Vec, + entries: HashSet, } impl NodeSet { - /// Convert into a [`Subgraph`] by pointing every index at `revision` - the - /// editor revision the node set was traversed against. - fn into_subgraph(self, revision: usize) -> Subgraph { + fn into_subgraph(self) -> Subgraph { Subgraph { - heads: self - .heads - .into_iter() - .map(|id| Selector { id, revision }) - .collect(), - nodes: self - .nodes - .into_iter() - .map(|id| Selector { id, revision }) - .collect(), + heads: self.heads.into_iter().map(|id| Selector { id }).collect(), + entries: self.entries.into_iter().map(|id| Selector { id }).collect(), } } } -impl Editor<'_, '_, M> { +impl Editor<'_, M> { /// Build a graph-based workspace projection framed from this editor. pub fn graph_workspace(&self) -> Result { let mut ws = self.graph_workspace_topology()?; // Every selector in the projection, so the status walks stay scoped to // the workspace rather than wandering down the full history. - let nodes: HashSet = ws + let entries: HashSet = ws .above_workspace - .nodes + .entries .iter() - .chain(ws.stacks.iter().flat_map(|stack| stack.nodes.iter())) + .chain(ws.stacks.iter().flat_map(|stack| stack.entries.iter())) .copied() .collect(); - let integration = self.integration(&nodes)?; + let integration = self.integration(&entries)?; ws.reference_status = - self.reference_statuses(&nodes, &integration.integrated_references)?; + self.reference_statuses(&entries, &integration.integrated_references)?; ws.commit_state = integration.commit_state; Ok(ws) } @@ -189,114 +179,104 @@ impl Editor<'_, '_, M> { return Ok(GraphWorkspace::empty()); }; - // In the case of no target sha: - // In PGM: We have one giant stack that contains all commits - // In A workspace: - // If we find a workspace commit, we have stacks that reach the full history. - // If we don't find a workspace commit, all commits from HEAD are considered above the workspace. + // Without a target sha: + // Outside the workspace branch, there is one giant stack with every commit. + // On the workspace branch: + // With a workspace commit, the stacks reach the full history. + // Without one, everything from HEAD counts as above the workspace. let ws_ref: gix::refs::FullName = WORKSPACE_REF_NAME.try_into()?; - let on_workspace = matches!( - &self.graph[entrypoint_ix], - Step::Reference { refname, .. } if *refname == ws_ref - ); + let on_workspace = self + .graph + .reference(entrypoint_ix) + .is_some_and(|(refname, _)| *refname == ws_ref); let target_ix = self.target_selector().map(|s| s.id); - let revision = self.history.current_revision(); - if on_workspace { - let head_not_target_commit = - all_until_optional_limit(&self.graph, entrypoint_ix, target_ix); + // The entrypoint is a reference: the region floods from the pick it resolves to + // (references carry no edges). The region is the rev-set `HEAD ^target`. + let entrypoint_pick = positions::resolve_to_pick(&self.graph, entrypoint_ix); + let mut region = NodeSet { + heads: vec![entrypoint_ix], + entries: entrypoint_pick + .map(|pick| { + traverse::all_until_optional_limit(&self.graph, pick.into(), target_ix) + .collect() + }) + .unwrap_or_default(), + }; + if on_workspace { // The workspace commit, if present, lives somewhere in `HEAD ^target`. - let workspace_commit = head_not_target_commit.nodes.iter().copied().find_map(|ix| { - let Step::Pick(Pick { id, .. }) = &self.graph[ix] else { - return None; - }; - let gix_commit = self.repo.find_commit(*id).ok()?; + let workspace_commit = region.entries.iter().copied().find_map(|ix| { + let id = self.graph.commit_id(ix)?; + let gix_commit = self.repo.find_commit(id).ok()?; is_managed_workspace_by_message(gix_commit.message_raw().ok()?).then_some(ix) }); if let Some(workspace_commit_ix) = workspace_commit { - let (above_workspace, stacks) = divide_workspace_into_stacks( - &self.graph, - head_not_target_commit, - workspace_commit_ix, - ); - - Ok(GraphWorkspace { - above_workspace: above_workspace.into_subgraph(revision), - workspace_commit: Some(self.new_selector(workspace_commit_ix)), - stacks: stacks - .into_iter() - .map(|s| s.into_subgraph(revision)) - .collect(), - reference_status: HashMap::new(), - commit_state: HashMap::new(), - }) + let (above_workspace, stacks) = + divide_workspace_into_stacks(&self.graph, region, workspace_commit_ix); + + Ok(GraphWorkspace::topology( + above_workspace.into_subgraph(), + Some(self.new_selector(workspace_commit_ix)), + stacks.into_iter().map(|s| s.into_subgraph()).collect(), + )) } else { - Ok(GraphWorkspace { - above_workspace: head_not_target_commit.into_subgraph(revision), - workspace_commit: None, - stacks: vec![], - reference_status: HashMap::new(), - commit_state: HashMap::new(), - }) + attach_flooded_refs(&self.graph, &mut region.entries, Some(entrypoint_ix)); + Ok(GraphWorkspace::topology( + region.into_subgraph(), + None, + vec![], + )) } } else { - // We're pegging. - let stack = all_until_optional_limit(&self.graph, entrypoint_ix, target_ix); - - Ok(GraphWorkspace { - above_workspace: Subgraph::empty(), - workspace_commit: None, - stacks: vec![stack.into_subgraph(revision)], - reference_status: HashMap::new(), - commit_state: HashMap::new(), - }) + // HEAD is on a plain branch, not the workspace: the whole `HEAD ^target` + // region is one stack. + attach_flooded_refs(&self.graph, &mut region.entries, Some(entrypoint_ix)); + Ok(GraphWorkspace::topology( + Subgraph::empty(), + None, + vec![region.into_subgraph()], + )) } } - /// The entrypoint (`HEAD`) reference node, or `None` if HEAD isn't on a ref. - fn head_index(&self) -> Option { + /// The entrypoint (`HEAD`) reference entry, or `None` if HEAD isn't on a ref. + fn head_index(&self) -> Option { self.checkouts - .iter() - .find_map(|Checkout::Head { selector, .. }| { - self.history - .normalize_selector(*selector) - .ok() - .map(|s| s.id) - }) + .first() + .map(|Checkout::Head { selector, .. }| selector.id) } - /// The target commit's node, if a target is configured and present. + /// The target commit's entry, if a target is configured and present. fn target_selector(&self) -> Option { - let target = self.workspace.graph.project_meta.target_commit_id?; - let selector = self.try_select_commit(target)?; - self.history.normalize_selector(selector).ok() + let target = self.project_meta.target_commit_id?; + self.try_select_commit(target) } /// Compute the per-reference status for every local-branch reference in the - /// projection, given the full projection `nodes` and the references already + /// projection, given the full projection `entries` and the references already /// classified as `integrated`. fn reference_statuses( &self, - nodes: &HashSet, + entries: &HashSet, integrated: &HashSet, ) -> Result> { // First pass: each local-branch reference's own remote ref and push status. let mut remote_by_ref = HashMap::new(); let mut status_by_ref = HashMap::new(); - for node in nodes { - let Step::Reference { refname, .. } = self.lookup_step(*node)? else { + for entry in entries { + let Step::Reference { refname, .. } = self.lookup_step(*entry)? else { continue; }; if refname.category() != Some(gix::refs::Category::LocalBranch) { continue; } - let (remote_ref, push_status) = self.reference_push_status(*node, refname.as_ref())?; - remote_by_ref.insert(*node, remote_ref); - status_by_ref.insert(*node, push_status); + let (remote_ref, push_status) = self.reference_push_status(*entry, refname.as_ref())?; + remote_by_ref.insert(*entry, remote_ref); + status_by_ref.insert(*entry, push_status); } // Integrated references override their push status: nothing to push once @@ -307,29 +287,29 @@ impl Editor<'_, '_, M> { } } - // Adjacency among projection nodes, used by the combined walk to reach + // Adjacency among projection entries, used by the combined walk to reach // parent references through intermediate commits. let mut parents_by_node: HashMap> = HashMap::new(); - for node in nodes { + for entry in entries { let parents = self - .direct_parents(*node)? + .position_parents(*entry)? .into_iter() - .filter_map(|(parent, _)| nodes.contains(&parent).then_some(parent)) + .filter(|parent| entries.contains(parent)) .collect(); - parents_by_node.insert(*node, parents); + parents_by_node.insert(*entry, parents); } // Second pass: fold parent references into the combined status. status_by_ref .iter() - .map(|(node, push_status)| { + .map(|(entry, push_status)| { Ok(( - *node, + *entry, ReferenceStatus { - remote_ref: remote_by_ref.get(node).cloned().flatten(), + remote_ref: remote_by_ref.get(entry).cloned().flatten(), push_status: *push_status, combined_push_status: combined_push_status( - *node, + *entry, *push_status, &parents_by_node, &status_by_ref, @@ -352,8 +332,8 @@ impl Editor<'_, '_, M> { /// empty-branch remote-tip fallback that `reference_integrated` has is /// intentionally not ported. Without a target there is nothing to integrate /// into, so both sets are empty. - fn integration(&self, nodes: &HashSet) -> Result { - let Some(target_ref) = self.workspace.graph.project_meta.target_ref.as_ref() else { + fn integration(&self, entries: &HashSet) -> Result { + let Some(target_ref) = self.project_meta.target_ref.as_ref() else { return Ok(Integration::default()); }; let Some(target_ref_selector) = self.try_select_reference(target_ref.as_ref()) else { @@ -380,7 +360,7 @@ impl Editor<'_, '_, M> { upstream = from_target_ref.iter().copied().collect(); } let upstream_ids = self.pick_ids(upstream.into_iter())?; - let workspace_ids = self.pick_ids(nodes.iter().copied())?; + let workspace_ids = self.pick_ids(entries.iter().copied())?; let content = but_core::changeset::compute_similarity_by_commit_ids( self.repo(), &upstream_ids, @@ -388,10 +368,10 @@ impl Editor<'_, '_, M> { true, )?; - let reference_names: HashMap = nodes + let reference_names: HashMap = entries .iter() - .filter_map(|node| match self.lookup_step(*node) { - Ok(Step::Reference { refname, .. }) => Some(Ok((*node, refname))), + .filter_map(|entry| match self.lookup_step(*entry) { + Ok(Step::Reference { refname, .. }) => Some(Ok((*entry, refname))), Ok(_) => None, Err(err) => Some(Err(err)), }) @@ -427,7 +407,7 @@ impl Editor<'_, '_, M> { if !remote_reachable.insert(selector) { continue; } - if !nodes.contains(&selector) + if !entries.contains(&selector) && let Step::Pick(Pick { id, .. }) = self.lookup_step(selector)? { remote_only_ids.push(id); @@ -439,20 +419,20 @@ impl Editor<'_, '_, M> { // Per-commit state: integrated wins over local-and-remote wins over local-only. let mut elapsed = std::time::Duration::default(); let mut commit_state = HashMap::new(); - for node in nodes { - let Step::Pick(Pick { id, .. }) = self.lookup_step(*node)? else { + for entry in entries { + let Step::Pick(Pick { id, .. }) = self.lookup_step(*entry)? else { continue; }; - let state = if is_commit_integrated(*node)? { + let state = if is_commit_integrated(*entry)? { CommitState::Integrated - } else if remote_reachable.contains(node) { + } else if remote_reachable.contains(entry) { CommitState::LocalAndRemote(id) } else if let Some(remote_id) = self.remote_similarity(id, &remote_lut, &mut elapsed)? { CommitState::LocalAndRemote(remote_id) } else { CommitState::LocalOnly }; - commit_state.insert(*node, state); + commit_state.insert(*entry, state); } // Per-reference: a local branch is integrated when all the commits it @@ -468,7 +448,7 @@ impl Editor<'_, '_, M> { let mut traversed_commits = false; 'walk: while let Some(tip) = tips.pop() { for (parent, _) in self.direct_parents(tip)? { - if !nodes.contains(&parent) { + if !entries.contains(&parent) { continue; } // A local branch owns its own commits, so stop there. Any @@ -554,8 +534,8 @@ impl Editor<'_, '_, M> { let (remote_ref, remote_selector) = self.remote_for_reference(refname); let Some(remote_selector) = remote_selector else { // Either no tracking branch exists, or the remote exists but its - // history is outside the workspace view and so can't be compared via - // the editor (rare under real traversals). + // history is outside the workspace view and so can't be compared + // within the editor (rare under real traversals). return Ok((remote_ref, PushStatus::CompletelyUnpushed)); }; @@ -566,8 +546,8 @@ impl Editor<'_, '_, M> { } /// Resolve `refname`'s remote-tracking ref name and a selector for it in the - /// editor graph, preferring its reference node and falling back to its tip - /// commit (limited traversals often drop the remote *ref* node while keeping + /// editor graph, preferring its reference entry and falling back to its tip + /// commit (limited traversals often drop the remote *ref* entry while keeping /// the commit it points at). Both are `None` when there is no tracking /// branch; the selector alone is `None` when the remote is outside the graph. fn remote_for_reference( @@ -608,18 +588,18 @@ fn push_status_from_ahead_behind(ahead_behind: AheadBehind) -> PushStatus { /// Fold a reference's own push status with those of the references below it: if /// any parent reference requires a force push, so does this one. /// -/// Generic over the node key so the force-escalation walk can be exercised with -/// plain keys in tests. `parents_by_node` may include non-reference nodes +/// Generic over the entry key so the force-escalation walk can be exercised with +/// plain keys in tests. `parents_by_node` may include non-reference entries /// (commits) as intermediate hops; only keys present in `status_by_ref` count as /// references. fn combined_push_status( reference: K, - own: PushStatus, + own_status: PushStatus, parents_by_node: &HashMap>, status_by_ref: &HashMap, ) -> PushStatus { // An integrated reference isn't pushed at all, so parents can't change that. - if matches!(own, PushStatus::Integrated) { + if matches!(own_status, PushStatus::Integrated) { return PushStatus::Integrated; } let mut tips = vec![reference]; @@ -635,79 +615,147 @@ fn combined_push_status( tips.push(*parent); } } - own -} - -/// All steps in `start ^limit`, or everything reachable from `start` when there -/// is no `limit`. -fn all_until_optional_limit( - graph: &StepGraph, - start: StepGraphIndex, - limit: Option, -) -> NodeSet { - NodeSet { - heads: vec![start], - nodes: traverse::all_until_optional_limit(graph, start, limit).collect(), - } + own_status } /// Split the region beneath the workspace commit into mutually-exclusive stacks, /// returning `(above_workspace, stacks)`. +/// +/// Membership is computed over PICKS: references are transparent for connectivity (they are +/// positions, not topology), so a shared ref entry cannot glue two distinct stacks +/// together. After the pick-flood, each reference joins the stack its position belongs to: +/// the stack of its entering child, else the stack of its resolved pick, and a group +/// hanging directly off the workspace commit keeps its own (possibly pick-less) stack โ€” +/// the empty-branch case. fn divide_workspace_into_stacks( - graph: &StepGraph, + graph: &GraphEditor, head_not_target: NodeSet, - workspace_commit_ix: StepGraphIndex, + workspace_commit_ix: EditorIndex, ) -> (NodeSet, Vec) { - // Each parent of the workspace commit seeds a stack. + // Each parent of the workspace commit seeds a stack, flooded pick-to-pick: every outgoing + // edge resolves through reference/tombstone steps to the pick beneath. let mut initial_stacks = graph - .edges_directed(workspace_commit_ix, Direction::Outgoing) - .map(|edge| NodeSet { - heads: vec![edge.target()], - nodes: [edge.target()].into(), - }) - .collect::>(); - - for stack in &mut initial_stacks { - let mut tips = stack.heads.clone(); - while let Some(tip) = tips.pop() { - for edge in graph.edges_directed(tip, Direction::Outgoing) { - if !head_not_target.nodes.contains(&edge.target()) { - continue; - } - if stack.nodes.insert(edge.target()) { - tips.push(edge.target()); + .parents(workspace_commit_ix) + .iter() + .copied() + .map(|head| { + let mut entries = std::collections::HashSet::new(); + let mut tips: Vec = Vec::new(); + if let Some(pick) = positions::resolve_to_pick(graph, head) + && head_not_target.entries.contains(&pick.into()) + { + entries.insert(pick.into()); + tips.push(pick); + } + while let Some(tip) = tips.pop() { + for parent in graph.parents(tip) { + let Some(pick) = positions::resolve_to_pick(graph, parent) else { + continue; + }; + if !head_not_target.entries.contains(&pick.into()) { + continue; + } + if entries.insert(pick.into()) { + tips.push(pick); + } } } - } - } + NodeSet { + heads: vec![head.into()], + entries, + } + }) + .collect::>(); - // Merge stacks that share any node (they aren't actually distinct). - // - // NOTE: a shared node here includes *reference* nodes, not just commits. - // A segment's head ref is its first node (see `creation.rs`), so stacks that - // converge on a shared segment - typically the target's - both point at its - // ref node and collapse into one, even when a target excludes the segment's - // commit. This is the known limitation documented on `GraphWorkspace::stacks`. + // Merge stacks that share any pick (they aren't actually distinct). The pop-loop takes + // from the back, so reverse first: stacks come out in the workspace commit's parent number order, + // first parent first. let mut deduplicated = vec![]; + initial_stacks.reverse(); while let Some(mut out) = initial_stacks.pop() { for bix in (0..initial_stacks.len()).rev() { #[expect(clippy::indexing_slicing)] if out - .nodes + .entries .iter() - .any(|o| initial_stacks[bix].nodes.contains(o)) + .any(|o| initial_stacks[bix].entries.contains(o)) { let b = initial_stacks.swap_remove(bix); - out.nodes.extend(b.nodes); + out.entries.extend(b.entries); out.heads.extend(b.heads); } } deduplicated.push(out); } - let mut outside = head_not_target.nodes.clone(); + // Each positioned reference joins the stack its position belongs to: the entering child's + // stack (with a group hanging straight off the workspace commit falling back to its + // pick's stack โ€” the workspace commit itself is in none), else the resolved pick's stack. + // References belonging to neither (e.g. the target's own ref above the excluded target + // commit) stay outside every stack. + for entry in graph.positioned_refs() { + let pick = positions::resolve_to_pick(graph, entry); + let entering = positions::edges_through(graph, entry); + let by_pick = |a: Option| { + a.and_then(|a| { + deduplicated + .iter() + .position(|s| s.entries.contains(&a.into())) + }) + }; + // Every entering edge must agree on the stack; a group entered from several stacks + // (or from the workspace commit itself) falls back to its pick's stack. A root group + // (no entering edges) has no stack โ€” no flood ever descended into it. + let pick_in_region = pick.is_some_and(|a| head_not_target.entries.contains(&a.into())); + let home = match entering.as_slice() { + // A root group: no flood ever descended into it โ€” no stack. + [] => None, + // A single entering edge follows its child's stack, even onto an excluded pick (a stack + // bottom resting on the target); a group hanging straight off the workspace + // commit falls back to its pick's stack. + [(child, _)] + if EditorIndex::from(*child) != workspace_commit_ix + && !graph.ambiguous_of(entry) => + { + deduplicated + .iter() + .position(|s| s.entries.contains(&EditorIndex::from(*child))) + .or_else(|| pick_in_region.then(|| by_pick(pick)).flatten()) + } + [_] => pick_in_region.then(|| by_pick(pick)).flatten(), + // A shared group: every edge must agree on the stack; otherwise it belongs to its + // pick's stack when that is in region, or nowhere. + many => { + let homes: Vec> = many + .iter() + .map(|(child, _)| { + if EditorIndex::from(*child) == workspace_commit_ix { + None + } else { + deduplicated + .iter() + .position(|s| s.entries.contains(&EditorIndex::from(*child))) + } + }) + .collect(); + match homes.as_slice() { + [Some(first), rest @ ..] if rest.iter().all(|h| *h == Some(*first)) => { + Some(*first) + } + _ => pick_in_region.then(|| by_pick(pick)).flatten(), + } + } + }; + if let Some(ix) = home { + #[expect(clippy::indexing_slicing)] + deduplicated[ix].entries.insert(entry.into()); + } + } + + let mut outside = head_not_target.entries.clone(); + attach_flooded_refs(graph, &mut outside, None); for stack in &deduplicated { - outside = outside.difference(&stack.nodes).copied().collect(); + outside = outside.difference(&stack.entries).copied().collect(); } outside.remove(&workspace_commit_ix); @@ -719,12 +767,50 @@ fn divide_workspace_into_stacks( .cloned() .filter(|h| *h != workspace_commit_ix) .collect(), - nodes: outside, + entries: outside, }; (above_workspace, deduplicated) } +/// Add the references that belong with the walked commits: groups entered by an in-region +/// child, plus โ€” when the walk started at a reference โ€” that reference and its group +/// members below it. Root groups nothing descends into (e.g. a remote ref stacked above a +/// local one) stay out; no walk ever passes through them. +fn attach_flooded_refs( + graph: &GraphEditor, + entries: &mut HashSet, + entry: Option, +) { + let mut additions: Vec = graph + .positioned_refs() + .filter(|&entry| { + // A group any in-region edge enters was flooded through before the walk + // stopped at a boundary โ€” membership is broader than stack assignment, which + // stays arity- and ambiguity-aware in `divide_workspace_into_stacks`. Co-located + // group members all share the same entering edges, so a lower member is + // attached with the whole group, while a root ref stacked above (its own entering set + // empty, e.g. a remote ref over the tip) stays out. + positions::edges_through(graph, entry) + .iter() + .any(|(child, _)| entries.contains(&EditorIndex::from(*child))) + }) + .collect(); + if let Some(entry) = entry.and_then(|e| e.as_ref()) + && let Some(entry_on) = graph.positioned_on(entry) + { + additions.push(entry); + let entry_edges = positions::edges_through(graph, entry); + let entry_depth = positions::ref_depth(graph, entry); + additions.extend(graph.positioned_refs().filter(|&other| { + graph.positioned_on(other) == Some(entry_on) + && positions::edges_through(graph, other) == entry_edges + && positions::ref_depth(graph, other) < entry_depth + })); + } + entries.extend(additions.into_iter().map(EditorIndex::from)); +} + #[cfg(test)] mod test { use std::collections::HashMap; diff --git a/crates/but-rebase/src/lib.rs b/crates/but-rebase/src/lib.rs index d0f2be85627..0d14b4da04c 100644 --- a/crates/but-rebase/src/lib.rs +++ b/crates/but-rebase/src/lib.rs @@ -394,23 +394,6 @@ fn reword_commit( )?) } -/// Replaces the tree of a commit for use in the rebase engine. -pub fn replace_commit_tree( - repo: &gix::Repository, - oid: gix::ObjectId, - new_tree: gix::ObjectId, -) -> Result { - let mut new_commit = repo.find_commit(oid)?.decode()?.to_owned()?; - new_commit.tree = new_tree; - Ok(commit::create( - repo, - new_commit, - DateMode::CommitterUpdateAuthorKeep, - SignCommit::IfSignCommitsEnabled, - None, - )?) -} - /// A reference that is an output of a rebase operation. /// This is simply a marker for where the actual reference should point to after the rebase operation. #[derive(Debug, Clone)] diff --git a/crates/but-rebase/src/merge.rs b/crates/but-rebase/src/merge.rs index 8f6324757a5..64a2e63992a 100644 --- a/crates/but-rebase/src/merge.rs +++ b/crates/but-rebase/src/merge.rs @@ -1,5 +1,5 @@ use anyhow::{Result, anyhow, bail}; -use bstr::{BString, ByteSlice}; +use bstr::ByteSlice as _; use but_core::{ RepositoryExt, commit::{SignCommit, TreeKind}, @@ -69,17 +69,17 @@ pub(crate) fn octopus( merge_options.clone(), )?; if merge.has_unresolved_conflicts(unresolved) { + let paths = merge + .conflicts + .iter() + .filter_map(|c| c.ours.location().to_str().ok().map(|p| format!("{p:?}"))) + .collect::>() + .join(", "); return Err(anyhow!( "Encountered conflict when merging tree {tree_to_merge}{details}", details = merge_conflict_details(&successfully_merged) ) - .context(ConflictErrorContext { - paths: merge - .conflicts - .iter() - .map(|c| c.ours.location().to_owned()) - .collect(), - })); + .context(format!("{paths} was/were conflicted when merging"))); } successfully_merged.push(tree_to_merge); ours = merge.tree.write()?.detach(); @@ -126,27 +126,6 @@ fn merge_conflict_details(successfully_merged: &[gix::ObjectId]) -> String { } } -/// A type that can be retrieved as an `anyhow` context to see if the rebase failed due to merge conflicts. -#[derive(Debug, Clone)] -pub struct ConflictErrorContext { - /// All the paths that were involved, limited to the current location of `our` side. - pub paths: Vec, -} - -impl std::fmt::Display for ConflictErrorContext { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{} was/were conflicted when merging", - self.paths - .iter() - .filter_map(|p| p.to_str().ok().map(|p| format!("{p:?}"))) - .collect::>() - .join(", ") - ) - } -} - #[cfg(test)] mod test_merge_confict_details { use std::str::FromStr; diff --git a/crates/but-rebase/tests/rebase/graph_rebase/change_id.rs b/crates/but-rebase/tests/rebase/graph_rebase/change_id.rs index f6b2894a4df..3c517a60d5f 100644 --- a/crates/but-rebase/tests/rebase/graph_rebase/change_id.rs +++ b/crates/but-rebase/tests/rebase/graph_rebase/change_id.rs @@ -1,7 +1,7 @@ //! Change id tests use anyhow::Result; -use but_graph::Graph; +use but_graph::Workspace; use but_rebase::graph_rebase::{Editor, LookupStep, Step, ToSelector}; use gix::prelude::ObjectIdExt; use snapbox::prelude::*; @@ -27,7 +27,7 @@ fn temporary_change_id_persisted() -> Result<()> { "#]] ); - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), @@ -36,8 +36,7 @@ fn temporary_change_id_persisted() -> Result<()> { .validated()?; // An operation to cause the parent we care about to be rebased - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let target_selector = target.to_selector(&editor)?; editor.replace(target_parent, Step::None)?; @@ -75,16 +74,14 @@ fn temporary_change_id_persisted() -> Result<()> { fn empty_commit_uses_default_change_id() -> Result<()> { let (repo, _tmpdir, mut meta) = fixture_writable("four-commits")?; - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let ec = editor.empty_commit()?; diff --git a/crates/but-rebase/tests/rebase/graph_rebase/conflictable_restriction.rs b/crates/but-rebase/tests/rebase/graph_rebase/conflictable_restriction.rs index 57eec9bde60..edfc2cbedeb 100644 --- a/crates/but-rebase/tests/rebase/graph_rebase/conflictable_restriction.rs +++ b/crates/but-rebase/tests/rebase/graph_rebase/conflictable_restriction.rs @@ -1,12 +1,12 @@ //! Exercises the step option for whether a step should be allowed to enter a conflicted state. use anyhow::{Result, bail}; -use but_graph::Graph; +use but_graph::Workspace; use but_rebase::{ commit::DateMode, graph_rebase::{Editor, LookupStep, Step, mutate::InsertSide}, }; -use but_testsupport::{cat_commit, graph_tree, visualize_commit_graph_all}; +use but_testsupport::{cat_commit, graph_dag, visualize_commit_graph_all}; use snapbox::prelude::*; use crate::utils::{fixture_writable, standard_options}; @@ -26,16 +26,14 @@ fn by_default_conflicts_are_allowed() -> Result<()> { "#]] ); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; // Replacing b with none will cause c to conflict let b = repo.rev_parse_single("b")?; @@ -43,21 +41,19 @@ fn by_default_conflicts_are_allowed() -> Result<()> { editor.replace(b_sel, Step::None)?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:main[๐ŸŒณ] - โ”œโ”€โ”€ ยท04d1892 (โŒ‚|1) โ–บc - โ””โ”€โ”€ ยท5e0ba46 (โŒ‚|1) โ–บa, โ–บb - โ””โ”€โ”€ โ–บ:1[1]:base - โ””โ”€โ”€ ๐Ÿยท6155f21 (โŒ‚|1) - +* ๐Ÿ‘‰ยท04d1892 (โŒ‚) โ–บc, โ–บmain[๐ŸŒณ] +* ยท5e0ba46 (โŒ‚) โ–บa, โ–บb +* ๐Ÿยท6155f21 (โŒ‚) โ–บbase "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); // We expect to see conflicted headers snapbox::assert_data_eq!( @@ -104,16 +100,14 @@ fn if_a_commit_has_been_configured_not_to_conflict_but_ends_up_conflicted_an_err "#]] ); - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; // Replacing b with none will cause c to conflict let b = repo.rev_parse_single("b")?; @@ -159,16 +153,14 @@ fn if_a_commit_has_been_configured_not_to_conflict_and_doesnt_end_up_conflicted_ "#]] ); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; // Insert an empty commit above b to cause c to get cherry picked with out a conflict let b = repo.rev_parse_single("b")?; @@ -188,25 +180,21 @@ fn if_a_commit_has_been_configured_not_to_conflict_and_doesnt_end_up_conflicted_ editor.replace(c_sel, Step::Pick(c_pick))?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:main[๐ŸŒณ] - โ””โ”€โ”€ ยท8b4d335 (โŒ‚|1) โ–บc - โ””โ”€โ”€ โ–บ:1[1]:b - โ”œโ”€โ”€ ยท7762cf9 (โŒ‚|1) - โ””โ”€โ”€ ยท3b3bd41 (โŒ‚|1) - โ””โ”€โ”€ โ–บ:2[2]:a - โ””โ”€โ”€ ยท5e0ba46 (โŒ‚|1) - โ””โ”€โ”€ โ–บ:3[3]:base - โ””โ”€โ”€ ๐Ÿยท6155f21 (โŒ‚|1) - +* ๐Ÿ‘‰ยท8b4d335 (โŒ‚) โ–บc, โ–บmain[๐ŸŒณ] +* ยท7762cf9 (โŒ‚) โ–บb +* ยท3b3bd41 (โŒ‚) +* ยท5e0ba46 (โŒ‚) โ–บa +* ๐Ÿยท6155f21 (โŒ‚) โ–บbase "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); // The rebase is successful because `c` remained unconflicted snapbox::assert_data_eq!( diff --git a/crates/but-rebase/tests/rebase/graph_rebase/disconnect.rs b/crates/but-rebase/tests/rebase/graph_rebase/disconnect.rs index ca95985badf..854f1e2601b 100644 --- a/crates/but-rebase/tests/rebase/graph_rebase/disconnect.rs +++ b/crates/but-rebase/tests/rebase/graph_rebase/disconnect.rs @@ -1,11 +1,11 @@ //! These tests exercise the disconnect operation. -use snapbox::IntoData; +use snapbox::prelude::*; use std::collections::HashSet; use anyhow::{Context, Result}; -use but_graph::Graph; -use but_rebase::graph_rebase::{Editor, Step, mutate}; -use but_testsupport::{git_status, graph_tree, visualize_commit_graph_all}; +use but_graph::Workspace; +use but_rebase::graph_rebase::{Editor, Step, selector}; +use but_testsupport::{git_status, graph_dag, visualize_commit_graph_all}; use gix::prelude::ObjectIdExt; use crate::utils::{fixture_writable, standard_options}; @@ -26,49 +26,47 @@ fn disconnect_and_remove_middle_commit_in_linear_history() -> Result<()> { ); snapbox::assert_data_eq!(git_status(&repo)?, snapbox::str![""]); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let b = repo.rev_parse_single("HEAD~")?.detach(); let b_selector = editor .select_commit(b) .context("Failed to find commit b in editor graph")?; - let target = mutate::SegmentDelimiter { + let target = selector::StepRange { child: b_selector, parent: b_selector, }; - editor.disconnect_segment_from( + editor.disconnect_range_from( target, - mutate::SelectorSet::All, - mutate::SelectorSet::All, + selector::SelectorSet::All, + selector::SelectorSet::All, false, )?; editor.replace(b_selector, Step::None)?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:main[๐ŸŒณ] - โ”œโ”€โ”€ ยทb4fd8ee (โŒ‚|1) - โ”œโ”€โ”€ ยทd591dfe (โŒ‚|1) - โ””โ”€โ”€ ๐Ÿยท35b8235 (โŒ‚|1) - +* ๐Ÿ‘‰ยทb4fd8ee (โŒ‚) โ–บmain[๐ŸŒณ] +* ยทd591dfe (โŒ‚) +* ๐Ÿยท35b8235 (โŒ‚) "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -100,15 +98,14 @@ fn disconnect_and_remove_two_middle_commits_in_linear_history() -> Result<()> { ); snapbox::assert_data_eq!(git_status(&repo)?, snapbox::str![""]); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let b = repo.rev_parse_single("HEAD~")?.detach(); let b_selector = editor @@ -119,34 +116,33 @@ fn disconnect_and_remove_two_middle_commits_in_linear_history() -> Result<()> { .select_commit(a) .context("Failed to find commit a in editor graph")?; - let delimiter = mutate::SegmentDelimiter { + let range = selector::StepRange { child: b_selector, parent: a_selector, }; - editor.disconnect_segment_from( - delimiter, - mutate::SelectorSet::All, - mutate::SelectorSet::All, + editor.disconnect_range_from( + range, + selector::SelectorSet::All, + selector::SelectorSet::All, false, )?; editor.replace(b_selector, Step::None)?; editor.replace(a_selector, Step::None)?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:main[๐ŸŒณ] - โ”œโ”€โ”€ ยท19f8134 (โŒ‚|1) - โ””โ”€โ”€ ๐Ÿยท35b8235 (โŒ‚|1) - +* ๐Ÿ‘‰ยท19f8134 (โŒ‚) โ–บmain[๐ŸŒณ] +* ๐Ÿยท35b8235 (โŒ‚) "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -181,54 +177,50 @@ fn disconnect_and_remove_commit_in_merge_history_rewires_children() -> Result<() ); snapbox::assert_data_eq!(git_status(&repo)?, snapbox::str![""]); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let a = repo.rev_parse_single("A")?.detach(); let a_selector = editor .select_commit(a) .context("Failed to find commit a in editor graph")?; - let delimiter = mutate::SegmentDelimiter { + let range = selector::StepRange { child: a_selector, parent: a_selector, }; - editor.disconnect_segment_from( - delimiter, - mutate::SelectorSet::All, - mutate::SelectorSet::All, + editor.disconnect_range_from( + range, + selector::SelectorSet::All, + selector::SelectorSet::All, false, )?; editor.replace(a_selector, Step::None)?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:with-inner-merge[๐ŸŒณ] - โ””โ”€โ”€ ยท4023659 (โŒ‚|1) - โ””โ”€โ”€ โ–บ:1[1]:anon: - โ””โ”€โ”€ ยท01c4df0 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:2[3]:anon: - โ”‚ โ””โ”€โ”€ ๐Ÿยท8f0d338 (โŒ‚|1) โ–บA, โ–บmain, โ–บtags/base - โ””โ”€โ”€ โ–บ:3[2]:B - โ””โ”€โ”€ ยท984fd1c (โŒ‚|1) - โ””โ”€โ”€ โ†’:2: - +* ๐Ÿ‘‰ยท4023659 (โŒ‚) โ–บwith-inner-merge[๐ŸŒณ] +* ยท01c4df0 (โŒ‚) +โ”œโ”€โ•ฎ +โ”‚ * ยท984fd1c (โŒ‚) โ–บB +โ”œโ”€โ•ฏ +* ๐Ÿยท8f0d338 (โŒ‚) โ–บA, โ–บmain, โ–บtags/base "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); let a_now = repo.rev_parse_single("A")?.detach(); let base = repo.rev_parse_single("base")?.detach(); @@ -276,60 +268,54 @@ fn disconnect_and_remove_merge_with_two_parents_and_two_children() -> Result<()> ); snapbox::assert_data_eq!(git_status(&repo)?, snapbox::str![""]); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let merge = repo.rev_parse_single("M")?.detach(); let merge_selector = editor .select_commit(merge) .context("Failed to find merge commit M in editor graph")?; - let delimiter = mutate::SegmentDelimiter { + let range = selector::StepRange { child: merge_selector, parent: merge_selector, }; - editor.disconnect_segment_from( - delimiter, - mutate::SelectorSet::All, - mutate::SelectorSet::All, + editor.disconnect_range_from( + range, + selector::SelectorSet::All, + selector::SelectorSet::All, false, )?; editor.replace(merge_selector, Step::None)?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:with-two-children[๐ŸŒณ] - โ””โ”€โ”€ ยท87269f1 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:C1 - โ”‚ โ””โ”€โ”€ ยท3e50be4 (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ โ–บ:3[2]:anon: - โ”‚ โ”‚ โ””โ”€โ”€ ยทbc0e772 (โŒ‚|1) โ–บM, โ–บP1 - โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:5[3]:main - โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท7674a5e (โŒ‚|1) โ–บtags/base - โ”‚ โ””โ”€โ”€ โ–บ:4[2]:P2 - โ”‚ โ””โ”€โ”€ ยท392a8f8 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:5: (main) - โ””โ”€โ”€ โ–บ:2[1]:C2 - โ””โ”€โ”€ ยทc291781 (โŒ‚|1) - โ”œโ”€โ”€ โ†’:3: - โ””โ”€โ”€ โ†’:4: (P2) - +* ๐Ÿ‘‰ยท87269f1 (โŒ‚) โ–บwith-two-children[๐ŸŒณ] +โ”œโ”€โ•ฎ +* โ”‚ ยท3e50be4 (โŒ‚) โ–บC1 +โ”œโ”€โ”€โ”€โ•ฎ +โ”‚ * โ”‚ ยทc291781 (โŒ‚) โ–บC2 +โ•ญโ”€โ”ดโ”€โ•ฎ +* โ”‚ ยทbc0e772 (โŒ‚) โ–บM, โ–บP1 +โ”‚ * ยท392a8f8 (โŒ‚) โ–บP2 +โ”œโ”€โ”€โ”€โ•ฏ +* ๐Ÿยท7674a5e (โŒ‚) โ–บmain, โ–บtags/base "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); let p1 = repo.rev_parse_single("P1")?.detach(); let p2 = repo.rev_parse_single("P2")?.detach(); @@ -410,15 +396,14 @@ fn disconnect_and_remove_merge_with_two_parents_and_two_children_from_one_side() ); snapbox::assert_data_eq!(git_status(&repo)?, snapbox::str![""]); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let merge = repo.rev_parse_single("M")?.detach(); let m_reference = "refs/heads/M".try_into()?; @@ -437,44 +422,38 @@ fn disconnect_and_remove_merge_with_two_parents_and_two_children_from_one_side() .select_reference(parent_one) .context("Failed to find P1 reference in editor graph")?; - let delimiter = mutate::SegmentDelimiter { + let range = selector::StepRange { child: m_reference_selector, parent: merge_commit_selector, }; - editor.disconnect_segment_from( - delimiter, - mutate::SelectorSet::Some(mutate::SomeSelectors::new(vec![child_one_selector])?), - mutate::SelectorSet::Some(mutate::SomeSelectors::new(vec![parent_one_selector])?), + editor.disconnect_range_from( + range, + selector::SelectorSet::Some(selector::SomeSelectors::new(vec![child_one_selector])?), + selector::SelectorSet::Some(selector::SomeSelectors::new(vec![parent_one_selector])?), false, )?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:with-two-children[๐ŸŒณ] - โ””โ”€โ”€ ยท9de031b (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:C1 - โ”‚ โ””โ”€โ”€ ยท54d0b0d (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:3[2]:P1 - โ”‚ โ””โ”€โ”€ ยทbc0e772 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:5[4]:main - โ”‚ โ””โ”€โ”€ ๐Ÿยท7674a5e (โŒ‚|1) โ–บtags/base - โ””โ”€โ”€ โ–บ:2[1]:C2 - โ””โ”€โ”€ ยท41cb528 (โŒ‚|1) - โ””โ”€โ”€ โ–บ:4[2]:M - โ””โ”€โ”€ ยท9f6b11a (โŒ‚|1) - โ””โ”€โ”€ โ–บ:6[3]:P2 - โ””โ”€โ”€ ยท392a8f8 (โŒ‚|1) - โ””โ”€โ”€ โ†’:5: (main) - +* ๐Ÿ‘‰ยท9de031b (โŒ‚) โ–บwith-two-children[๐ŸŒณ] +โ”œโ”€โ•ฎ +* โ”‚ ยท54d0b0d (โŒ‚) โ–บC1 +* โ”‚ ยทbc0e772 (โŒ‚) โ–บP1 +โ”‚ * ยท41cb528 (โŒ‚) โ–บC2 +โ”‚ * ยท9f6b11a (โŒ‚) โ–บM +โ”‚ * ยท392a8f8 (โŒ‚) โ–บP2 +โ”œโ”€โ•ฏ +* ๐Ÿยท7674a5e (โŒ‚) โ–บmain, โ–บtags/base "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); let p1 = repo.rev_parse_single("P1")?.detach(); let m = repo.rev_parse_single("M")?.detach(); @@ -551,15 +530,14 @@ fn disconnect_remove_merge_with_two_parents_and_two_children_children_only() -> ); snapbox::assert_data_eq!(git_status(&repo)?, snapbox::str![""]); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let merge = repo.rev_parse_single("M")?.detach(); let m_reference = "refs/heads/M".try_into()?; @@ -574,42 +552,54 @@ fn disconnect_remove_merge_with_two_parents_and_two_children_children_only() -> .select_reference(parent_one) .context("Failed to find P1 reference in editor graph")?; - let delimiter = mutate::SegmentDelimiter { + let range = selector::StepRange { child: m_reference_selector, parent: merge_commit_selector, }; - editor.disconnect_segment_from( - delimiter, - mutate::SelectorSet::None, - mutate::SelectorSet::Some(mutate::SomeSelectors::new(vec![parent_one_selector])?), + editor.disconnect_range_from( + range, + selector::SelectorSet::None, + selector::SelectorSet::Some(selector::SomeSelectors::new(vec![parent_one_selector])?), false, )?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:with-two-children[๐ŸŒณ] - โ””โ”€โ”€ ยทb87b6c9 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:C1 - โ”‚ โ””โ”€โ”€ ยท76ecfed (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:3[2]:M - โ”‚ โ””โ”€โ”€ ยท9f6b11a (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[3]:P2 - โ”‚ โ””โ”€โ”€ ยท392a8f8 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:5[4]:main - โ”‚ โ””โ”€โ”€ ๐Ÿยท7674a5e (โŒ‚|1) โ–บtags/base - โ””โ”€โ”€ โ–บ:2[1]:C2 - โ””โ”€โ”€ ยท41cb528 (โŒ‚|1) - โ””โ”€โ”€ โ†’:3: (M) - +* ๐Ÿ‘‰ยทb87b6c9 (โŒ‚) โ–บwith-two-children[๐ŸŒณ] +โ”œโ”€โ•ฎ +* โ”‚ ยท76ecfed (โŒ‚) โ–บC1 +โ”‚ * ยท41cb528 (โŒ‚) โ–บC2 +โ”œโ”€โ•ฏ +* ยท9f6b11a (โŒ‚) โ–บM +* ยท392a8f8 (โŒ‚) โ–บP2 +* ๐Ÿยท7674a5e (โŒ‚) โ–บmain, โ–บtags/base "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + // Same workspace, one substrate difference: the write-through arena keeps the + // disconnected P1 region alive as external context (its on-disk ref revives it), + // while the fresh walk above never reaches it. + snapbox::assert_data_eq!( + graph_dag(&ws), + snapbox::str![[r#" +* ๐Ÿ‘‰ยทb87b6c9 (โŒ‚) โ–บwith-two-children[๐ŸŒณ] +โ”œโ”€โ•ฎ +* โ”‚ ยท76ecfed (โŒ‚) โ–บC1 +โ”‚ * ยท41cb528 (โŒ‚) โ–บC2 +โ”œโ”€โ•ฏ +* ยท9f6b11a (โŒ‚) โ–บM +* ยท392a8f8 (โŒ‚) โ–บP2 +โ”‚ * ๐ŸŸฃbc0e772 โ–บP1 +โ”œโ”€โ•ฏ +* ๐Ÿยท7674a5e (โŒ‚) โ–บmain, โ–บtags/base +"#]] + ); let p1 = repo.rev_parse_single("P1")?.detach(); let p2 = repo.rev_parse_single("P2")?.detach(); @@ -694,15 +684,14 @@ fn disconnect_fails_when_parents_to_disconnect_is_none() -> Result<()> { let before = visualize_commit_graph_all(&repo)?; - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let merge = repo.rev_parse_single("M")?.detach(); let m_reference = "refs/heads/M".try_into()?; @@ -717,16 +706,16 @@ fn disconnect_fails_when_parents_to_disconnect_is_none() -> Result<()> { .select_commit(child_one) .context("Failed to find C1 referent in editor graph")?; - let delimiter = mutate::SegmentDelimiter { + let range = selector::StepRange { child: m_reference_selector, parent: merge_commit_selector, }; let err = editor - .disconnect_segment_from( - delimiter, - mutate::SelectorSet::Some(mutate::SomeSelectors::new(vec![child_one_selector])?), - mutate::SelectorSet::None, + .disconnect_range_from( + range, + selector::SelectorSet::Some(selector::SomeSelectors::new(vec![child_one_selector])?), + selector::SelectorSet::None, false, ) .expect_err("expected disconnect to fail for parents=SelectorSet::None"); @@ -737,32 +726,27 @@ fn disconnect_fails_when_parents_to_disconnect_is_none() -> Result<()> { ); let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:with-two-children[๐ŸŒณ] - โ””โ”€โ”€ ยทd1cc4c7 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:C1 - โ”‚ โ””โ”€โ”€ ยทf94f259 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:3[2]:M - โ”‚ โ””โ”€โ”€ ยทc5d1178 (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ โ–บ:4[3]:P1 - โ”‚ โ”‚ โ””โ”€โ”€ ยทbc0e772 (โŒ‚|1) - โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:6[4]:main - โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท7674a5e (โŒ‚|1) โ–บtags/base - โ”‚ โ””โ”€โ”€ โ–บ:5[3]:P2 - โ”‚ โ””โ”€โ”€ ยท392a8f8 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:6: (main) - โ””โ”€โ”€ โ–บ:2[1]:C2 - โ””โ”€โ”€ ยทce6aca9 (โŒ‚|1) - โ””โ”€โ”€ โ†’:3: (M) - +* ๐Ÿ‘‰ยทd1cc4c7 (โŒ‚) โ–บwith-two-children[๐ŸŒณ] +โ”œโ”€โ•ฎ +* โ”‚ ยทf94f259 (โŒ‚) โ–บC1 +โ”‚ * ยทce6aca9 (โŒ‚) โ–บC2 +โ”œโ”€โ•ฏ +* ยทc5d1178 (โŒ‚) โ–บM +โ”œโ”€โ•ฎ +* โ”‚ ยทbc0e772 (โŒ‚) โ–บP1 +โ”‚ * ยท392a8f8 (โŒ‚) โ–บP2 +โ”œโ”€โ•ฏ +* ๐Ÿยท7674a5e (โŒ‚) โ–บmain, โ–บtags/base "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); let after = visualize_commit_graph_all(&repo)?; assert_eq!(before, after, "graph should remain unchanged on failure"); @@ -776,15 +760,14 @@ fn disconnect_fails_fast_if_parent_to_disconnect_is_not_direct_parent() -> Resul let before = visualize_commit_graph_all(&repo)?; - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let merge = repo.rev_parse_single("M")?.detach(); let m_reference = "refs/heads/M".try_into()?; @@ -799,16 +782,16 @@ fn disconnect_fails_fast_if_parent_to_disconnect_is_not_direct_parent() -> Resul .select_commit(child_one) .context("Failed to find C1 referent in editor graph")?; - let delimiter = mutate::SegmentDelimiter { + let range = selector::StepRange { child: m_reference_selector, parent: merge_commit_selector, }; let err = editor - .disconnect_segment_from( - delimiter, - mutate::SelectorSet::Some(mutate::SomeSelectors::new(vec![child_one_selector])?), - mutate::SelectorSet::Some(mutate::SomeSelectors::new(vec![child_one_selector])?), + .disconnect_range_from( + range, + selector::SelectorSet::Some(selector::SomeSelectors::new(vec![child_one_selector])?), + selector::SelectorSet::Some(selector::SomeSelectors::new(vec![child_one_selector])?), false, ) .expect_err("expected disconnect to fail for non-parent selector"); @@ -819,32 +802,27 @@ fn disconnect_fails_fast_if_parent_to_disconnect_is_not_direct_parent() -> Resul ); let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:with-two-children[๐ŸŒณ] - โ””โ”€โ”€ ยทd1cc4c7 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:C1 - โ”‚ โ””โ”€โ”€ ยทf94f259 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:3[2]:M - โ”‚ โ””โ”€โ”€ ยทc5d1178 (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ โ–บ:4[3]:P1 - โ”‚ โ”‚ โ””โ”€โ”€ ยทbc0e772 (โŒ‚|1) - โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:6[4]:main - โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท7674a5e (โŒ‚|1) โ–บtags/base - โ”‚ โ””โ”€โ”€ โ–บ:5[3]:P2 - โ”‚ โ””โ”€โ”€ ยท392a8f8 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:6: (main) - โ””โ”€โ”€ โ–บ:2[1]:C2 - โ””โ”€โ”€ ยทce6aca9 (โŒ‚|1) - โ””โ”€โ”€ โ†’:3: (M) - +* ๐Ÿ‘‰ยทd1cc4c7 (โŒ‚) โ–บwith-two-children[๐ŸŒณ] +โ”œโ”€โ•ฎ +* โ”‚ ยทf94f259 (โŒ‚) โ–บC1 +โ”‚ * ยทce6aca9 (โŒ‚) โ–บC2 +โ”œโ”€โ•ฏ +* ยทc5d1178 (โŒ‚) โ–บM +โ”œโ”€โ•ฎ +* โ”‚ ยทbc0e772 (โŒ‚) โ–บP1 +โ”‚ * ยท392a8f8 (โŒ‚) โ–บP2 +โ”œโ”€โ•ฏ +* ๐Ÿยท7674a5e (โŒ‚) โ–บmain, โ–บtags/base "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); let after = visualize_commit_graph_all(&repo)?; assert_eq!(before, after, "graph should remain unchanged on failure"); @@ -858,15 +836,14 @@ fn disconnect_fails_fast_if_child_to_disconnect_is_not_direct_child() -> Result< let before = visualize_commit_graph_all(&repo)?; - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let merge = repo.rev_parse_single("M")?.detach(); let m_reference = "refs/heads/M".try_into()?; @@ -881,52 +858,47 @@ fn disconnect_fails_fast_if_child_to_disconnect_is_not_direct_child() -> Result< .select_reference(parent_one) .context("Failed to find P1 reference in editor graph")?; - let delimiter = mutate::SegmentDelimiter { + let range = selector::StepRange { child: m_reference_selector, parent: merge_commit_selector, }; let err = editor - .disconnect_segment_from( - delimiter, - mutate::SelectorSet::Some(mutate::SomeSelectors::new(vec![parent_one_selector])?), - mutate::SelectorSet::Some(mutate::SomeSelectors::new(vec![parent_one_selector])?), + .disconnect_range_from( + range, + selector::SelectorSet::Some(selector::SomeSelectors::new(vec![parent_one_selector])?), + selector::SelectorSet::Some(selector::SomeSelectors::new(vec![parent_one_selector])?), false, ) .expect_err("expected disconnect to fail for non-child selector"); assert!( err.to_string() - .contains("requested child is not a direct parent"), + .contains("requested child is not a direct child"), "unexpected error: {err:#}" ); let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:with-two-children[๐ŸŒณ] - โ””โ”€โ”€ ยทd1cc4c7 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:C1 - โ”‚ โ””โ”€โ”€ ยทf94f259 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:3[2]:M - โ”‚ โ””โ”€โ”€ ยทc5d1178 (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ โ–บ:4[3]:P1 - โ”‚ โ”‚ โ””โ”€โ”€ ยทbc0e772 (โŒ‚|1) - โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:6[4]:main - โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท7674a5e (โŒ‚|1) โ–บtags/base - โ”‚ โ””โ”€โ”€ โ–บ:5[3]:P2 - โ”‚ โ””โ”€โ”€ ยท392a8f8 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:6: (main) - โ””โ”€โ”€ โ–บ:2[1]:C2 - โ””โ”€โ”€ ยทce6aca9 (โŒ‚|1) - โ””โ”€โ”€ โ†’:3: (M) - +* ๐Ÿ‘‰ยทd1cc4c7 (โŒ‚) โ–บwith-two-children[๐ŸŒณ] +โ”œโ”€โ•ฎ +* โ”‚ ยทf94f259 (โŒ‚) โ–บC1 +โ”‚ * ยทce6aca9 (โŒ‚) โ–บC2 +โ”œโ”€โ•ฏ +* ยทc5d1178 (โŒ‚) โ–บM +โ”œโ”€โ•ฎ +* โ”‚ ยทbc0e772 (โŒ‚) โ–บP1 +โ”‚ * ยท392a8f8 (โŒ‚) โ–บP2 +โ”œโ”€โ•ฏ +* ๐Ÿยท7674a5e (โŒ‚) โ–บmain, โ–บtags/base "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); let after = visualize_commit_graph_all(&repo)?; assert_eq!(before, after, "graph should remain unchanged on failure"); diff --git a/crates/but-rebase/tests/rebase/graph_rebase/edge.rs b/crates/but-rebase/tests/rebase/graph_rebase/edge.rs index 58d8a7882f4..ea5b1a3b090 100644 --- a/crates/but-rebase/tests/rebase/graph_rebase/edge.rs +++ b/crates/but-rebase/tests/rebase/graph_rebase/edge.rs @@ -1,8 +1,8 @@ -//! These tests exercise the add_step, add_edge and remove_edge operations. +//! These tests exercise the add_step, insert_edge and remove_edge operations. use anyhow::Result; use but_core::Commit; -use but_graph::Graph; +use but_graph::Workspace; use but_rebase::graph_rebase::{Editor, Step, testing::Testing as _}; use gix::prelude::ObjectIdExt; @@ -12,15 +12,14 @@ use crate::utils::{fixture, fixture_writable, standard_options}; fn adding_a_step_returns_a_selector_that_can_be_connected_into_the_graph() -> Result<()> { let (repo, _tmpdir, mut meta) = fixture_writable("four-commits")?; - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let c = repo.rev_parse_single("HEAD")?.detach(); let a = repo.rev_parse_single("HEAD~2")?.detach(); @@ -33,8 +32,8 @@ fn adding_a_step_returns_a_selector_that_can_be_connected_into_the_graph() -> Re let new_commit = repo.write_object(commit.inner)?.detach(); let new_selector = editor.add_step(Step::new_pick(new_commit))?; - editor.add_edge(c_selector, new_selector, 1)?; - editor.add_edge(new_selector, a_selector, 0)?; + editor.insert_edge(c_selector, new_selector, 1)?; + editor.insert_edge(new_selector, a_selector, 0)?; let steps_ascii = editor .steps_ascii() @@ -58,31 +57,38 @@ fn adding_a_step_returns_a_selector_that_can_be_connected_into_the_graph() -> Re } #[test] -fn adding_an_existing_edge_causes_an_error() -> Result<()> { +fn inserting_at_an_occupied_parent_number_shifts_existing_parents() -> Result<()> { let (repo, mut meta) = fixture("four-commits")?; - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; - let b = repo.rev_parse_single("HEAD~")?.detach(); + let c = repo.rev_parse_single("HEAD")?.detach(); let a = repo.rev_parse_single("HEAD~2")?.detach(); - let b_selector = editor.select_commit(b)?; + let c_selector = editor.select_commit(c)?; let a_selector = editor.select_commit(a)?; - let err = editor - .add_edge(b_selector, a_selector, 0) - .expect_err("adding an existing edge order should fail"); + // `c`'s only parent is `b` at parent number 0; inserting `a` there makes `a` the first + // parent and shifts `b` to the merge side. + editor.insert_edge(c_selector, a_selector, 0)?; - assert_eq!( - err.to_string(), - "An edge with desired order 0 already exists" + snapbox::assert_data_eq!( + editor.steps_ascii(), + snapbox::str![[r#" +โ—Ž refs/heads/main +โ— 120e3a9 c +โ”œโ”€โ•ฎ +โ”‚ โ— a96434e b +โ”œโ”€โ•ฏ +โ— d591dfe a +โ— 35b8235 base +"#]] ); Ok(()) @@ -93,15 +99,14 @@ fn adding_an_existing_edge_causes_an_error() -> Result<()> { fn adding_an_edge_that_introduces_a_cycle_causes_an_error() -> Result<()> { let (repo, mut meta) = fixture("four-commits")?; - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let c = repo.rev_parse_single("HEAD")?.detach(); let a = repo.rev_parse_single("HEAD~2")?.detach(); @@ -109,7 +114,7 @@ fn adding_an_edge_that_introduces_a_cycle_causes_an_error() -> Result<()> { let a_selector = editor.select_commit(a)?; let err = editor - .add_edge(a_selector, c_selector, 1) + .insert_edge(a_selector, c_selector, 1) .expect_err("adding an edge to an existing descendant should fail"); assert_eq!(err.to_string(), "BUG: Add edge introduces a cycle"); @@ -121,22 +126,21 @@ fn adding_an_edge_that_introduces_a_cycle_causes_an_error() -> Result<()> { fn adding_a_valid_edge_is_successful() -> Result<()> { let (repo, mut meta) = fixture("merge-in-the-middle")?; - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let a = repo.rev_parse_single("A")?.detach(); let b = repo.rev_parse_single("B")?.detach(); let a_selector = editor.select_commit(a)?; let b_selector = editor.select_commit(b)?; - editor.add_edge(a_selector, b_selector, 1)?; + editor.insert_edge(a_selector, b_selector, 1)?; snapbox::assert_data_eq!( editor.steps_ascii(), @@ -146,10 +150,9 @@ fn adding_a_valid_edge_is_successful() -> Result<()> { โ— 2fc288c Merge branch 'B' into with-inner-merge โ”œโ”€โ•ฎ โ—Ž โ”‚ refs/heads/A -โ— โ”‚ add59d2 A: 10 lines on top -โ”œโ”€โ”€โ”€โ•ฎ -โ”‚ โ—Ž โ”‚ refs/heads/B -โ”‚ โ”œโ”€โ•ฏ +โ— โ”‚ add59d2 A: 10 lines on top +โ”œโ”€โ•ฎ +โ”‚ โ—Ž refs/heads/B โ”‚ โ— 984fd1c C: new file with 10 lines โ”œโ”€โ•ฏ โ—Ž refs/heads/main @@ -165,15 +168,14 @@ fn adding_a_valid_edge_is_successful() -> Result<()> { fn remove_edge_returns_no_orders_when_no_edges_found() -> Result<()> { let (repo, mut meta) = fixture("four-commits")?; - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let c = repo.rev_parse_single("HEAD")?.detach(); let a = repo.rev_parse_single("HEAD~2")?.detach(); @@ -189,15 +191,14 @@ fn remove_edge_returns_no_orders_when_no_edges_found() -> Result<()> { fn removing_an_existing_edge_returns_its_order_and_allows_readding_it() -> Result<()> { let (repo, mut meta) = fixture("four-commits")?; - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let b = repo.rev_parse_single("HEAD~")?.detach(); let a = repo.rev_parse_single("HEAD~2")?.detach(); @@ -205,7 +206,7 @@ fn removing_an_existing_edge_returns_its_order_and_allows_readding_it() -> Resul let a_selector = editor.select_commit(a)?; assert_eq!(editor.remove_edges(b_selector, a_selector)?, vec![0]); - editor.add_edge(b_selector, a_selector, 0)?; + editor.insert_edge(b_selector, a_selector, 0)?; snapbox::assert_data_eq!( editor.steps_ascii(), diff --git a/crates/but-rebase/tests/rebase/graph_rebase/editor_creation.rs b/crates/but-rebase/tests/rebase/graph_rebase/editor_creation.rs index a2454cf6541..f6db318562f 100644 --- a/crates/but-rebase/tests/rebase/graph_rebase/editor_creation.rs +++ b/crates/but-rebase/tests/rebase/graph_rebase/editor_creation.rs @@ -1,8 +1,8 @@ use anyhow::Result; -use but_graph::{Graph, init::Tip}; +use but_graph::{Workspace, walk::Seed}; use but_rebase::graph_rebase::{Editor, GraphEditorOptions, testing::Testing as _}; -use but_testsupport::{StackState, graph_tree, visualize_commit_graph_all}; -use snapbox::IntoData; +use but_testsupport::{StackState, graph_dag, visualize_commit_graph_all}; +use snapbox::prelude::*; use crate::{ graph_rebase::add_stack_with_segments, @@ -34,11 +34,9 @@ fn four_commits() -> Result<()> { "#]] ); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; snapbox::assert_data_eq!( editor.steps_ascii(), @@ -73,11 +71,9 @@ fn merge_in_the_middle() -> Result<()> { .raw() ); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; snapbox::assert_data_eq!( editor.steps_ascii(), @@ -123,11 +119,9 @@ fn three_branches_merged() -> Result<()> { .raw() ); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; snapbox::assert_data_eq!( editor.steps_ascii(), @@ -169,24 +163,19 @@ fn many_references() -> Result<()> { "#]] ); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:main[๐ŸŒณ] - โ”œโ”€โ”€ ยท120e3a9 (โŒ‚|1) - โ”œโ”€โ”€ ยทa96434e (โŒ‚|1) - โ”œโ”€โ”€ ยทd591dfe (โŒ‚|1) โ–บX, โ–บY, โ–บZ - โ””โ”€โ”€ ๐Ÿยท35b8235 (โŒ‚|1) - +* ๐Ÿ‘‰ยท120e3a9 (โŒ‚) โ–บmain[๐ŸŒณ] +* ยทa96434e (โŒ‚) +* ยทd591dfe (โŒ‚) โ–บX, โ–บY, โ–บZ +* ๐Ÿยท35b8235 (โŒ‚) "#]] ); - - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; snapbox::assert_data_eq!( editor.steps_ascii(), @@ -226,32 +215,24 @@ fn first_parent_leg_long() -> Result<()> { .raw() ); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:with-inner-merge[๐ŸŒณ] - โ””โ”€โ”€ ยท6ac5745 (โŒ‚|1) - โ””โ”€โ”€ โ–บ:1[1]:anon: - โ””โ”€โ”€ ยทd20f547 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:2[2]:A - โ”‚ โ”œโ”€โ”€ ยท198d2e4 (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ ยท7325853 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ ยทadd59d2 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[3]:main - โ”‚ โ””โ”€โ”€ ๐Ÿยท8f0d338 (โŒ‚|1) โ–บtags/base - โ””โ”€โ”€ โ–บ:3[2]:B - โ””โ”€โ”€ ยท984fd1c (โŒ‚|1) - โ””โ”€โ”€ โ†’:4: (main) - +* ๐Ÿ‘‰ยท6ac5745 (โŒ‚) โ–บwith-inner-merge[๐ŸŒณ] +* ยทd20f547 (โŒ‚) +โ”œโ”€โ•ฎ +* โ”‚ ยท198d2e4 (โŒ‚) โ–บA +* โ”‚ ยท7325853 (โŒ‚) +* โ”‚ ยทadd59d2 (โŒ‚) +โ”‚ * ยท984fd1c (โŒ‚) โ–บB +โ”œโ”€โ•ฏ +* ๐Ÿยท8f0d338 (โŒ‚) โ–บmain, โ–บtags/base "#]] ); - - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; snapbox::assert_data_eq!( editor.steps_ascii(), @@ -297,32 +278,24 @@ fn second_parent_leg_long() -> Result<()> { .raw() ); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:with-inner-merge[๐ŸŒณ] - โ””โ”€โ”€ ยทa6775ea (โŒ‚|1) - โ””โ”€โ”€ โ–บ:1[1]:anon: - โ””โ”€โ”€ ยทb85214b (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:2[2]:A - โ”‚ โ””โ”€โ”€ ยทadd59d2 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[3]:main - โ”‚ โ””โ”€โ”€ ๐Ÿยท8f0d338 (โŒ‚|1) โ–บtags/base - โ””โ”€โ”€ โ–บ:3[2]:B - โ”œโ”€โ”€ ยทf87f875 (โŒ‚|1) - โ”œโ”€โ”€ ยทcb181a0 (โŒ‚|1) - โ””โ”€โ”€ ยท984fd1c (โŒ‚|1) - โ””โ”€โ”€ โ†’:4: (main) - +* ๐Ÿ‘‰ยทa6775ea (โŒ‚) โ–บwith-inner-merge[๐ŸŒณ] +* ยทb85214b (โŒ‚) +โ”œโ”€โ•ฎ +* โ”‚ ยทadd59d2 (โŒ‚) โ–บA +โ”‚ * ยทf87f875 (โŒ‚) โ–บB +โ”‚ * ยทcb181a0 (โŒ‚) +โ”‚ * ยท984fd1c (โŒ‚) +โ”œโ”€โ•ฏ +* ๐Ÿยท8f0d338 (โŒ‚) โ–บmain, โ–บtags/base "#]] ); - - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; snapbox::assert_data_eq!( editor.steps_ascii(), @@ -372,34 +345,27 @@ fn workspace_with_empty_stack() -> Result<()> { .raw() ); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท74bcc92 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:3[1]:stack-1 -โ”‚ โ”‚ โ”œโ”€โ”€ ยท2169646 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ””โ”€โ”€ ยท46ef828 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:4[2]:anon: -โ”‚ โ”‚ โ”œโ”€โ”€ ยทf555940 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ”‚ โ”œโ”€โ”€ ยทd664be0 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:5[1]:stack-2 -โ”‚ โ””โ”€โ”€ โ†’:4: -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ โ–บ:2[1]:main <> origin/main โ†’:1: - โ””โ”€โ”€ ยทa0f2ac5 (โŒ‚|โœ“|10) - โ””โ”€โ”€ โ†’:4: - +* ยทa0f2ac5 (โŒ‚|โœ“) โ–บmain, โ–บorigin/main <> origin/main +โ”‚ * ๐Ÿ‘‰ยท74bcc92 (โŒ‚|๐Ÿ˜) +โ•ญโ”€โ”ค +โ”‚ * ยท2169646 (โŒ‚|๐Ÿ˜) โ–บstack-1 +โ”‚ * ยท46ef828 (โŒ‚|๐Ÿ˜) +โ”œโ”€โ•ฏ +* ยทf555940 (โŒ‚|๐Ÿ˜|โœ“) โ–บstack-2 +* ยทd664be0 (โŒ‚|๐Ÿ˜|โœ“) +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) +layout: + materialized parents: 74bcc92: 2169646 f555940 + empty chain anchors: 2169646^ f555940 "#]] ); - - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; snapbox::assert_data_eq!( editor.steps_ascii(), @@ -444,32 +410,22 @@ fn workspace_with_three_empty_stacks() -> Result<()> { "#]] ); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยทa26ae77 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:4[1]:stack-1 -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:3[2]:anon: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:5[1]:stack-2 -โ”‚ โ”‚ โ””โ”€โ”€ โ†’:3: -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:6[1]:stack-3 -โ”‚ โ””โ”€โ”€ โ†’:3: -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ โ–บ:2[1]:main <> origin/main โ†’:1: - โ””โ”€โ”€ ยท1cf9cf4 (โŒ‚|โœ“|10) - โ””โ”€โ”€ โ†’:3: - +* ยท1cf9cf4 (โŒ‚|โœ“) โ–บmain, โ–บorigin/main <> origin/main +โ”‚ * ๐Ÿ‘‰ยทa26ae77 (โŒ‚|๐Ÿ˜) +โ”œโ”€โ•ฏ +* ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บstack-1, โ–บstack-2, โ–บstack-3 +layout: + materialized parents: a26ae77: fafd9d0 fafd9d0 fafd9d0 + empty chain anchors: fafd9d0 fafd9d0 fafd9d0 "#]] ); - - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; snapbox::assert_data_eq!( editor.steps_ascii(), @@ -493,6 +449,210 @@ fn workspace_with_three_empty_stacks() -> Result<()> { Ok(()) } +/// The most common production shape: the target `origin/main` sits at the BASE of the +/// stacks, squarely on the mutability walk from HEAD. Remote-tracking refs must stay +/// immutable regardless of reachability โ€” only push/fetch may move them. +#[test] +fn workspace_with_target_at_stack_base() -> Result<()> { + let (repo, _tmpdir, mut meta) = fixture_writable("workspace-two-stacks")?; + + add_stack_with_segments(&mut meta, 1, "stack-a", StackState::InWorkspace, &[]); + add_stack_with_segments(&mut meta, 2, "stack-b", StackState::InWorkspace, &[]); + + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 1162583 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|\ +| * afc3f8f (stack-b) B2 +| * b3ee99c B1 +* | 49c06ff (stack-a) A2 +* | ff76d2f A1 +|/ +* 965998b (origin/main, main) base + +"#]] + .raw() + ); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; + + snapbox::assert_data_eq!( + editor.steps_ascii(), + snapbox::str![[r#" +โ—Ž refs/heads/gitbutler/workspace +โ— 1162583 GitButler Workspace Commit +โ”œโ”€โ•ฎ +โ—Ž โ”‚ refs/heads/stack-a +โ— โ”‚ 49c06ff A2 +โ— โ”‚ ff76d2f A1 +โ”‚ โ—Ž refs/heads/stack-b +โ”‚ โ— afc3f8f B2 +โ”‚ โ— b3ee99c B1 +โ”œโ”€โ•ฏ +โ—Ž refs/heads/main +โ”‚ โ—Ž refs/remotes/origin/main (immutable) +โ”œโ”€โ•ฏ +โ— 965998b base +"#]] + ); + + Ok(()) +} + +/// Same shape but WITHOUT a local `main`: the target `origin/main` itself names the +/// segment owning the integrated base history, placing it directly on the mutability +/// walk from HEAD. It must still come out immutable. +#[test] +fn workspace_with_target_at_stack_base_no_local_main() -> Result<()> { + let (repo, _tmpdir, mut meta) = fixture_writable("workspace-two-stacks")?; + repo.find_reference("refs/heads/main")?.delete()?; + + add_stack_with_segments(&mut meta, 1, "stack-a", StackState::InWorkspace, &[]); + add_stack_with_segments(&mut meta, 2, "stack-b", StackState::InWorkspace, &[]); + + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 1162583 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|\ +| * afc3f8f (stack-b) B2 +| * b3ee99c B1 +* | 49c06ff (stack-a) A2 +* | ff76d2f A1 +|/ +* 965998b (origin/main) base + +"#]] + .raw() + ); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; + + snapbox::assert_data_eq!( + editor.steps_ascii(), + snapbox::str![[r#" +โ—Ž refs/heads/gitbutler/workspace +โ— 1162583 GitButler Workspace Commit +โ”œโ”€โ•ฎ +โ—Ž โ”‚ refs/heads/stack-a +โ— โ”‚ 49c06ff A2 +โ— โ”‚ ff76d2f A1 +โ”‚ โ—Ž refs/heads/stack-b +โ”‚ โ— afc3f8f B2 +โ”‚ โ— b3ee99c B1 +โ”œโ”€โ•ฏ +โ—Ž refs/remotes/origin/main (immutable) +โ— 965998b base +"#]] + ); + + Ok(()) +} + +/// Ops that would move, rename, delete, or unhook an immutable reference fail loudly +/// instead of succeeding session-only (materialization would refuse the write anyway). +#[test] +fn ops_on_immutable_refs_fail() -> Result<()> { + use but_rebase::graph_rebase::{ + Step, + mutate::InsertSide, + selector::{SelectorSet, StepRange}, + }; + + let (repo, _tmpdir, mut meta) = fixture_writable("workspace-two-stacks")?; + repo.find_reference("refs/heads/main")?.delete()?; + add_stack_with_segments(&mut meta, 1, "stack-a", StackState::InWorkspace, &[]); + add_stack_with_segments(&mut meta, 2, "stack-b", StackState::InWorkspace, &[]); + + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; + + let remote = editor.select_reference("refs/remotes/origin/main".try_into()?)?; + let stack_a = editor.select_reference("refs/heads/stack-a".try_into()?)?; + let expected = "reference refs/remotes/origin/main is immutable \ + and cannot be moved, renamed, or deleted"; + + // Delete and rename (replace on a ref entry). + snapbox::assert_data_eq!( + editor.replace(remote, Step::None).unwrap_err().to_string(), + snapbox::str![ + "reference refs/remotes/origin/main is immutable and cannot be moved, renamed, or deleted" + ] + ); + // Re-point (an edge FROM a reference is its downward link). + assert_eq!( + editor + .insert_edge(remote, stack_a, 0) + .unwrap_err() + .to_string(), + expected + ); + // Unhook (disconnecting the lone-reference segment). + assert_eq!( + editor + .disconnect_range_from( + StepRange { + child: remote, + parent: remote, + }, + SelectorSet::All, + SelectorSet::All, + false, + ) + .unwrap_err() + .to_string(), + expected + ); + // Move (inserting the lone-reference segment elsewhere). + assert_eq!( + editor + .insert_range( + stack_a, + StepRange { + child: remote, + parent: remote, + }, + InsertSide::Above, + ) + .unwrap_err() + .to_string(), + expected + ); + // Splitting the reference off its pick (a pick inserted below re-points it). + let new_commit = { + let base = repo.rev_parse_single("refs/remotes/origin/main")?; + let base = base.object()?.into_commit(); + repo.write_object(gix::objs::Commit { + tree: base.tree_id()?.detach(), + parents: Default::default(), + author: base.author()?.into(), + committer: base.committer()?.into(), + encoding: None, + message: "detached".into(), + extra_headers: vec![], + })? + .detach() + }; + assert_eq!( + editor + .insert(remote, Step::new_pick(new_commit), InsertSide::Below) + .unwrap_err() + .to_string(), + expected + ); + + // A mutable ref standing beside the immutable one is untouched by the guard. + editor.replace(stack_a, Step::None)?; + + Ok(()) +} + #[test] fn commit_with_two_parents() -> Result<()> { let (repo, _tmpdir, mut meta) = fixture_writable("single-commit")?; @@ -512,24 +672,17 @@ fn commit_with_two_parents() -> Result<()> { .raw() ); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:main[๐ŸŒณ] - โ””โ”€โ”€ ยทd70d863 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:anon: - โ”‚ โ””โ”€โ”€ ๐Ÿยท35b8235 (โŒ‚|1) - โ””โ”€โ”€ โ†’:1: - +* ๐Ÿ‘‰ยทd70d863 (โŒ‚) โ–บmain[๐ŸŒณ] +* ๐Ÿยท35b8235 (โŒ‚) "#]] ); - - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; snapbox::assert_data_eq!( editor.steps_ascii(), @@ -552,10 +705,9 @@ fn includes_extra_refs_in_editor_creation() -> Result<()> { let main_ref = gix::refs::FullName::try_from("refs/heads/main")?; { - let graph = Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? .validated()?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; snapbox::assert_data_eq!( editor.steps_ascii(), @@ -580,11 +732,11 @@ fn includes_extra_refs_in_editor_creation() -> Result<()> { } { - let graph = Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? .validated()?; - let mut ws = graph.into_workspace()?; let editor = Editor::create_with_opts( - &mut ws, + ws.commit_graph(), + ws.project_meta(), &mut *meta, &repo, &GraphEditorOptions { @@ -643,32 +795,24 @@ fn merge_first_parent_older_than_second() -> Result<()> { .raw() ); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:first-parent[๐ŸŒณ] - โ””โ”€โ”€ ยท738ea18 (โŒ‚|1) - โ””โ”€โ”€ โ–บ:1[1]:anon: - โ””โ”€โ”€ ยท408ca26 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:3[2]:anon: - โ”‚ โ””โ”€โ”€ ยท2854fa2 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[3]:main - โ”‚ โ””โ”€โ”€ ๐Ÿยท793a434 (โŒ‚|1) โ–บtags/base - โ””โ”€โ”€ โ–บ:2[2]:second-parent - โ”œโ”€โ”€ ยท75369b0 (โŒ‚|1) - โ”œโ”€โ”€ ยท553bbf7 (โŒ‚|1) - โ””โ”€โ”€ ยท72614bb (โŒ‚|1) - โ””โ”€โ”€ โ†’:4: (main) - +* ๐Ÿ‘‰ยท738ea18 (โŒ‚) โ–บfirst-parent[๐ŸŒณ] +* ยท408ca26 (โŒ‚) +โ”œโ”€โ•ฎ +* โ”‚ ยท2854fa2 (โŒ‚) +โ”‚ * ยท75369b0 (โŒ‚) โ–บsecond-parent +โ”‚ * ยท553bbf7 (โŒ‚) +โ”‚ * ยท72614bb (โŒ‚) +โ”œโ”€โ•ฏ +* ๐Ÿยท793a434 (โŒ‚) โ–บmain, โ–บtags/base "#]] ); - - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; snapbox::assert_data_eq!( editor.steps_ascii(), @@ -712,18 +856,18 @@ fn immutable_entrypoints_propogate_until_mutable_entrypoints() -> Result<()> { "#]] ); - let graph = Graph::from_commit_traversal_tips( + let ws = Workspace::from_seeds( &repo, [ - Tip::entrypoint( + Seed::entrypoint( repo.rev_parse_single("refs/heads/implicit-mut")?.detach(), Some("refs/heads/implicit-mut".try_into()?), ), - Tip::reachable( + Seed::reachable( repo.rev_parse_single("refs/heads/explicit-const")?.detach(), Some("refs/heads/explicit-const".try_into()?), ), - Tip::reachable( + Seed::reachable( repo.rev_parse_single("refs/heads/explicit-const-2")? .detach(), Some("refs/heads/explicit-const-2".try_into()?), @@ -736,35 +880,31 @@ fn immutable_entrypoints_propogate_until_mutable_entrypoints() -> Result<()> { .validated()?; snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), + graph_dag(&ws), snapbox::str![[r#" - -โ”œโ”€โ”€ โ–บ:0[0]:explicit-const -โ”‚ โ””โ”€โ”€ ยทbe4ae80 (โŒ‚) โ–บmain -โ”‚ โ””โ”€โ”€ โ–บ:3[1]:implicit-const -โ”‚ โ””โ”€โ”€ ยท120e3a9 (โŒ‚) -โ”‚ โ””โ”€โ”€ โ–บ:6[2]:explicit-mut -โ”‚ โ””โ”€โ”€ ยทa96434e (โŒ‚) -โ”‚ โ””โ”€โ”€ โ–บ:5[3]:foo -โ”‚ โ”œโ”€โ”€ ยทd591dfe (โŒ‚|1) -โ”‚ โ””โ”€โ”€ ๐Ÿยท35b8235 (โŒ‚|1) -โ””โ”€โ”€ โ–บ:1[0]:explicit-const-2 - โ””โ”€โ”€ ยทd9fa122 (โŒ‚) - โ””โ”€โ”€ โ–บ:4[1]:implicit-const-2 - โ””โ”€โ”€ ยท85bccf0 (โŒ‚) - โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:2[2]:implicit-mut - โ””โ”€โ”€ ยทc8dd361 (โŒ‚|1) - โ””โ”€โ”€ โ†’:5: (foo) - +* ยทbe4ae80 (โŒ‚) โ–บexplicit-const, โ–บmain +* ยท120e3a9 (โŒ‚) โ–บimplicit-const +* ยทa96434e (โŒ‚) โ–บexplicit-mut +โ”‚ * ยทd9fa122 (โŒ‚) โ–บexplicit-const-2 +โ”‚ * ยท85bccf0 (โŒ‚) โ–บimplicit-const-2 +โ”‚ * ๐Ÿ‘‰ยทc8dd361 (โŒ‚) โ–บimplicit-mut +โ”œโ”€โ•ฏ +* ยทd591dfe (โŒ‚) โ–บfoo +* ๐Ÿยท35b8235 (โŒ‚) "#]] ); - let mut ws = graph.into_workspace()?; let opts = GraphEditorOptions { extra_mutable_refs: vec!["refs/heads/explicit-mut".try_into()?], ..Default::default() }; - let editor = Editor::create_with_opts(&mut ws, &mut *meta, &repo, &opts)?; + let editor = Editor::create_with_opts( + ws.commit_graph(), + ws.project_meta(), + &mut *meta, + &repo, + &opts, + )?; snapbox::assert_data_eq!( editor.steps_ascii(), diff --git a/crates/but-rebase/tests/rebase/graph_rebase/graph_workspace.rs b/crates/but-rebase/tests/rebase/graph_rebase/graph_workspace.rs index 2892426caa2..912f25f17c2 100644 --- a/crates/but-rebase/tests/rebase/graph_rebase/graph_workspace.rs +++ b/crates/but-rebase/tests/rebase/graph_rebase/graph_workspace.rs @@ -12,10 +12,10 @@ use anyhow::Result; use but_core::ref_metadata::ProjectMeta; -use but_graph::Graph; +use but_graph::Workspace; use but_rebase::graph_rebase::Editor; use but_testsupport::visualize_commit_graph_all; -use snapbox::IntoData; +use snapbox::prelude::*; use crate::utils::{fixture_writable, standard_options}; @@ -25,19 +25,18 @@ use crate::utils::{fixture_writable, standard_options}; fn render(fixture: &str, target: Option<&str>) -> Result { let (repo, _tmp, mut meta) = fixture_writable(fixture)?; - let graph = - Graph::from_head(&repo, &*meta, ProjectMeta::default(), standard_options())?.validated()?; - let mut ws = graph.into_workspace()?; + let mut ws = Workspace::from_head(&repo, &*meta, ProjectMeta::default(), standard_options())? + .validated()?; // The projection bounds stacks at the target commit, so wire it onto the // workspace graph that the editor reads from. - ws.graph.project_meta = ProjectMeta { + *ws.project_meta_mut() = ProjectMeta { target_commit_id: target .map(|t| repo.rev_parse_single(t).map(|id| id.detach())) .transpose()?, ..Default::default() }; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; editor.graph_workspace_ascii() } @@ -193,12 +192,12 @@ fn three_stacks_same_base_collapse() -> Result<()> { /// Two divergent branches sharing `base`, bounded by a target at `base`. /// -/// They still merge into a single stack: the target excludes the base *commit*, -/// but the `main`/`origin/main` *ref node* sitting just above it survives and is -/// reachable from both branches, so they share a node and collapse. A target -/// alone does not separate stacks that branch off a common ref. +/// They stay SEPARATE: stack membership is computed over picks (references are positions, not +/// topology), so the `main` ref sitting above the excluded base commit cannot glue the two +/// branches together โ€” and sitting on the excluded commit, it belongs to neither stack. +/// This used to be the known limitation documented on `GraphWorkspace::stacks`. #[test] -fn divergent_stacks_sharing_base_merge_with_target() -> Result<()> { +fn divergent_stacks_sharing_excluded_base_stay_separate_with_target() -> Result<()> { let (repo, _tmp, _meta) = fixture_writable("workspace-two-stacks")?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -229,11 +228,11 @@ fn divergent_stacks_sharing_base_merge_with_target() -> Result<()> { โ—Ž refs/heads/stack-a โ— 49c06ff A2 โ— ff76d2f A1 -โ”‚ โ—Ž refs/heads/stack-b -โ”‚ โ— afc3f8f B2 -โ”‚ โ— b3ee99c B1 -โ”œโ”€โ•ฏ -โ—Ž refs/heads/main + +# Stack 1 +โ—Ž refs/heads/stack-b +โ— afc3f8f B2 +โ— b3ee99c B1 "#]] ); Ok(()) @@ -357,22 +356,22 @@ fn disjoint_stacks_stay_separate() -> Result<()> { โ— f97c026 GitButler Workspace Commit # Stack 0 -โ—Ž refs/heads/stack-b -โ— cb7021b B2 -โ— ce3278a B1 - -# Stack 1 โ—Ž refs/heads/stack-a โ— 49c06ff A2 โ— ff76d2f A1 โ—Ž refs/heads/main โ— 965998b base + +# Stack 1 +โ—Ž refs/heads/stack-b +โ— cb7021b B2 +โ— ce3278a B1 "#]] ); Ok(()) } -/// The direct contrast to `divergent_stacks_sharing_base_merge_with_target`: +/// The direct contrast to `divergent_stacks_sharing_excluded_base_stay_separate_with_target`: /// the *same* target (`main`), but the two stacks share no node, so they stay /// separate. This is the shape that sidesteps the known limitation documented on /// `GraphWorkspace::stacks` - the target trims `base` off `stack-a` without the @@ -389,15 +388,14 @@ fn disjoint_stacks_stay_separate_with_target() -> Result<()> { โ— f97c026 GitButler Workspace Commit # Stack 0 -โ—Ž refs/heads/stack-b -โ— cb7021b B2 -โ— ce3278a B1 - -# Stack 1 โ—Ž refs/heads/stack-a โ— 49c06ff A2 โ— ff76d2f A1 -โ—Ž refs/heads/main + +# Stack 1 +โ—Ž refs/heads/stack-b +โ— cb7021b B2 +โ— ce3278a B1 "#]] ); Ok(()) diff --git a/crates/but-rebase/tests/rebase/graph_rebase/insert.rs b/crates/but-rebase/tests/rebase/graph_rebase/insert.rs index 11a15450732..42df08b230f 100644 --- a/crates/but-rebase/tests/rebase/graph_rebase/insert.rs +++ b/crates/but-rebase/tests/rebase/graph_rebase/insert.rs @@ -1,8 +1,8 @@ //! These tests exercise the insert operation. use anyhow::{Context, Result}; -use but_graph::Graph; +use but_graph::Workspace; use but_rebase::graph_rebase::{Editor, Step, mutate::InsertSide}; -use but_testsupport::{git_status, graph_tree, visualize_commit_graph_all}; +use but_testsupport::{git_status, graph_dag, visualize_commit_graph_all}; use snapbox::prelude::*; use crate::utils::{fixture_writable, standard_options}; @@ -28,16 +28,14 @@ fn insert_below_merge_commit() -> Result<()> { ); snapbox::assert_data_eq!(git_status(&repo)?, snapbox::str![""]); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let merge_id = repo.rev_parse_single("HEAD~")?; @@ -55,28 +53,24 @@ fn insert_below_merge_commit() -> Result<()> { editor.insert(selector, Step::new_pick(new_commit), InsertSide::Below)?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:with-inner-merge[๐ŸŒณ] - โ”œโ”€โ”€ ยทf699c45 (โŒ‚|1) - โ””โ”€โ”€ ยท16b7c68 (โŒ‚|1) - โ””โ”€โ”€ โ–บ:1[1]:anon: - โ””โ”€โ”€ ยท8ca0053 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:2[2]:A - โ”‚ โ””โ”€โ”€ ยทadd59d2 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[3]:main - โ”‚ โ””โ”€โ”€ ๐Ÿยท8f0d338 (โŒ‚|1) โ–บtags/base - โ””โ”€โ”€ โ–บ:3[2]:B - โ””โ”€โ”€ ยท984fd1c (โŒ‚|1) - โ””โ”€โ”€ โ†’:4: (main) - +* ๐Ÿ‘‰ยทf699c45 (โŒ‚) โ–บwith-inner-merge[๐ŸŒณ] +* ยท16b7c68 (โŒ‚) +* ยท8ca0053 (โŒ‚) +โ”œโ”€โ•ฎ +* โ”‚ ยทadd59d2 (โŒ‚) โ–บA +โ”‚ * ยท984fd1c (โŒ‚) โ–บB +โ”œโ”€โ•ฏ +* ๐Ÿยท8f0d338 (โŒ‚) โ–บmain, โ–บtags/base "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -130,16 +124,14 @@ fn insert_below_merge_commit_excluded_mappings() -> Result<()> { ); snapbox::assert_data_eq!(git_status(&repo)?, snapbox::str![""]); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let merge_id = repo.rev_parse_single("HEAD~")?; @@ -161,28 +153,24 @@ fn insert_below_merge_commit_excluded_mappings() -> Result<()> { )?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:with-inner-merge[๐ŸŒณ] - โ”œโ”€โ”€ ยทf699c45 (โŒ‚|1) - โ””โ”€โ”€ ยท16b7c68 (โŒ‚|1) - โ””โ”€โ”€ โ–บ:1[1]:anon: - โ””โ”€โ”€ ยท8ca0053 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:2[2]:A - โ”‚ โ””โ”€โ”€ ยทadd59d2 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[3]:main - โ”‚ โ””โ”€โ”€ ๐Ÿยท8f0d338 (โŒ‚|1) โ–บtags/base - โ””โ”€โ”€ โ–บ:3[2]:B - โ””โ”€โ”€ ยท984fd1c (โŒ‚|1) - โ””โ”€โ”€ โ†’:4: (main) - +* ๐Ÿ‘‰ยทf699c45 (โŒ‚) โ–บwith-inner-merge[๐ŸŒณ] +* ยท16b7c68 (โŒ‚) +* ยท8ca0053 (โŒ‚) +โ”œโ”€โ•ฎ +* โ”‚ ยทadd59d2 (โŒ‚) โ–บA +โ”‚ * ยท984fd1c (โŒ‚) โ–บB +โ”œโ”€โ•ฏ +* ๐Ÿยท8f0d338 (โŒ‚) โ–บmain, โ–บtags/base "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -235,16 +223,14 @@ fn insert_above_commit_with_two_children() -> Result<()> { ); snapbox::assert_data_eq!(git_status(&repo)?, snapbox::str![""]); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let base_id = repo.rev_parse_single("base")?; @@ -262,28 +248,24 @@ fn insert_above_commit_with_two_children() -> Result<()> { editor.insert(selector, Step::new_pick(new_commit), InsertSide::Above)?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:with-inner-merge[๐ŸŒณ] - โ””โ”€โ”€ ยท42f9ff4 (โŒ‚|1) - โ””โ”€โ”€ โ–บ:1[1]:anon: - โ””โ”€โ”€ ยท5219d30 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:2[2]:A - โ”‚ โ””โ”€โ”€ ยท72d9d9b (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[3]:main - โ”‚ โ”œโ”€โ”€ ยท3dc4e45 (โŒ‚|1) โ–บtags/base - โ”‚ โ””โ”€โ”€ ๐Ÿยท8f0d338 (โŒ‚|1) - โ””โ”€โ”€ โ–บ:3[2]:B - โ””โ”€โ”€ ยทdf0cf44 (โŒ‚|1) - โ””โ”€โ”€ โ†’:4: (main) - +* ๐Ÿ‘‰ยท42f9ff4 (โŒ‚) โ–บwith-inner-merge[๐ŸŒณ] +* ยท5219d30 (โŒ‚) +โ”œโ”€โ•ฎ +* โ”‚ ยท72d9d9b (โŒ‚) โ–บA +โ”‚ * ยทdf0cf44 (โŒ‚) โ–บB +โ”œโ”€โ•ฏ +* ยท3dc4e45 (โŒ‚) โ–บmain, โ–บtags/base +* ๐Ÿยท8f0d338 (โŒ‚) "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, diff --git a/crates/but-rebase/tests/rebase/graph_rebase/insert_segment.rs b/crates/but-rebase/tests/rebase/graph_rebase/insert_range.rs similarity index 67% rename from crates/but-rebase/tests/rebase/graph_rebase/insert_segment.rs rename to crates/but-rebase/tests/rebase/graph_rebase/insert_range.rs index eb749e769e0..95e35ca51e0 100644 --- a/crates/but-rebase/tests/rebase/graph_rebase/insert_segment.rs +++ b/crates/but-rebase/tests/rebase/graph_rebase/insert_range.rs @@ -1,10 +1,10 @@ //! These tests exercise the insert segment operation. use anyhow::{Context, Result}; use bstr::ByteSlice; -use but_graph::Graph; -use but_rebase::graph_rebase::{Editor, mutate}; -use but_testsupport::{git_status, graph_tree, visualize_commit_graph, visualize_commit_graph_all}; -use snapbox::IntoData; +use but_graph::Workspace; +use but_rebase::graph_rebase::{Editor, mutate, selector}; +use but_testsupport::{git_status, graph_dag, visualize_commit_graph, visualize_commit_graph_all}; +use snapbox::prelude::*; use crate::utils::{fixture_writable, standard_options}; @@ -49,15 +49,14 @@ fn insert_single_node_segment_above() -> Result<()> { ); snapbox::assert_data_eq!(git_status(&repo)?, snapbox::str![""]); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let a = repo.rev_parse_single("A")?.detach(); let a_selector = editor @@ -68,39 +67,36 @@ fn insert_single_node_segment_above() -> Result<()> { .select_reference(b) .context("Failed to find reference b in editor graph")?; - let delimiter = mutate::SegmentDelimiter { + let range = selector::StepRange { child: a_selector, parent: a_selector, }; - editor.insert_segment(b_selector, delimiter, mutate::InsertSide::Above)?; + editor.insert_range(b_selector, range, mutate::InsertSide::Above)?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:main[๐ŸŒณ] - โ””โ”€โ”€ ยทee7f107 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:A - โ”‚ โ””โ”€โ”€ ยท69221b4 (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ โ–บ:3[2]:B - โ”‚ โ”‚ โ”œโ”€โ”€ ยทa748762 (โŒ‚|1) - โ”‚ โ”‚ โ””โ”€โ”€ ยท62e05ba (โŒ‚|1) - โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:4[3]:anon: - โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท8f0d338 (โŒ‚|1) โ–บtags/base - โ”‚ โ””โ”€โ”€ โ†’:4: - โ””โ”€โ”€ โ–บ:2[1]:C - โ”œโ”€โ”€ ยท930563a (โŒ‚|1) - โ”œโ”€โ”€ ยท68a2fc3 (โŒ‚|1) - โ””โ”€โ”€ ยท984fd1c (โŒ‚|1) - โ””โ”€โ”€ โ†’:4: - +* ๐Ÿ‘‰ยทee7f107 (โŒ‚) โ–บmain[๐ŸŒณ] +โ”œโ”€โ•ฎ +* โ”‚ ยท69221b4 (โŒ‚) โ–บA +โ”œโ”€โ”€โ”€โ•ฎ +* โ”‚ โ”‚ ยทa748762 (โŒ‚) โ–บB +* โ”‚ โ”‚ ยท62e05ba (โŒ‚) +โ”œโ”€โ”€โ”€โ•ฏ +โ”‚ * ยท930563a (โŒ‚) โ–บC +โ”‚ * ยท68a2fc3 (โŒ‚) +โ”‚ * ยท984fd1c (โŒ‚) +โ”œโ”€โ•ฏ +* ๐Ÿยท8f0d338 (โŒ‚) โ–บtags/base "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -147,15 +143,14 @@ fn insert_single_node_segment_below() -> Result<()> { ); snapbox::assert_data_eq!(git_status(&repo)?, snapbox::str![""]); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let a = repo.rev_parse_single("A")?.detach(); let a_selector = editor @@ -166,41 +161,37 @@ fn insert_single_node_segment_below() -> Result<()> { .select_commit(b) .context("Failed to find commit b in editor graph")?; - let delimiter = mutate::SegmentDelimiter { + let range = selector::StepRange { child: a_selector, parent: a_selector, }; - editor.insert_segment(b_selector, delimiter, mutate::InsertSide::Below)?; + editor.insert_range(b_selector, range, mutate::InsertSide::Below)?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:main[๐ŸŒณ] - โ””โ”€โ”€ ยทb005f3c (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[2]:A - โ”‚ โ””โ”€โ”€ ยท7f0cc55 (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ โ–บ:4[3]:anon: - โ”‚ โ”‚ โ””โ”€โ”€ ยท62e05ba (โŒ‚|1) - โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:5[4]:anon: - โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท8f0d338 (โŒ‚|1) โ–บtags/base - โ”‚ โ””โ”€โ”€ โ†’:5: - โ”œโ”€โ”€ โ–บ:2[1]:B - โ”‚ โ””โ”€โ”€ ยทa3301fe (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:1: (A) - โ””โ”€โ”€ โ–บ:3[1]:C - โ”œโ”€โ”€ ยท930563a (โŒ‚|1) - โ”œโ”€โ”€ ยท68a2fc3 (โŒ‚|1) - โ””โ”€โ”€ ยท984fd1c (โŒ‚|1) - โ””โ”€โ”€ โ†’:5: - +* ๐Ÿ‘‰ยทb005f3c (โŒ‚) โ–บmain[๐ŸŒณ] +โ”œโ”€โ”ฌโ”€โ•ฎ +โ”‚ * โ”‚ ยทa3301fe (โŒ‚) โ–บB +โ”œโ”€โ•ฏ โ”‚ +* โ”‚ ยท7f0cc55 (โŒ‚) โ–บA +โ”œโ”€โ•ฎ โ”‚ +* โ”‚ โ”‚ ยท62e05ba (โŒ‚) +โ”œโ”€โ•ฏ โ”‚ +โ”‚ * ยท930563a (โŒ‚) โ–บC +โ”‚ * ยท68a2fc3 (โŒ‚) +โ”‚ * ยท984fd1c (โŒ‚) +โ”œโ”€โ”€โ”€โ•ฏ +* ๐Ÿยท8f0d338 (โŒ‚) โ–บtags/base "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -248,15 +239,14 @@ fn insert_multi_node_segment_above() -> Result<()> { ); snapbox::assert_data_eq!(git_status(&repo)?, snapbox::str![""]); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let a = repo.rev_parse_single("A")?.detach(); let a_selector = editor @@ -271,40 +261,36 @@ fn insert_multi_node_segment_above() -> Result<()> { .select_commit(b_parent) .context("Failed to find parent of commit b in editor graph")?; - let delimiter = mutate::SegmentDelimiter { + let range = selector::StepRange { child: b_selector, parent: b_parent_selector, }; - editor.insert_segment(a_selector, delimiter, mutate::InsertSide::Above)?; + editor.insert_range(a_selector, range, mutate::InsertSide::Above)?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:main[๐ŸŒณ] - โ””โ”€โ”€ ยท61b2679 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:anon: - โ”‚ โ””โ”€โ”€ ยท758c8a3 (โŒ‚|1) โ–บA, โ–บB - โ”‚ โ””โ”€โ”€ โ–บ:3[2]:anon: - โ”‚ โ””โ”€โ”€ ยทdb40ffc (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ โ–บ:4[3]:anon: - โ”‚ โ”‚ โ””โ”€โ”€ ยทadd59d2 (โŒ‚|1) - โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:5[4]:anon: - โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท8f0d338 (โŒ‚|1) โ–บtags/base - โ”‚ โ””โ”€โ”€ โ†’:5: - โ””โ”€โ”€ โ–บ:2[1]:C - โ”œโ”€โ”€ ยท930563a (โŒ‚|1) - โ”œโ”€โ”€ ยท68a2fc3 (โŒ‚|1) - โ””โ”€โ”€ ยท984fd1c (โŒ‚|1) - โ””โ”€โ”€ โ†’:5: - +* ๐Ÿ‘‰ยท61b2679 (โŒ‚) โ–บmain[๐ŸŒณ] +โ”œโ”€โ•ฎ +* โ”‚ ยท758c8a3 (โŒ‚) โ–บA, โ–บB +* โ”‚ ยทdb40ffc (โŒ‚) +โ”œโ”€โ”€โ”€โ•ฎ +* โ”‚ โ”‚ ยทadd59d2 (โŒ‚) +โ”œโ”€โ”€โ”€โ•ฏ +โ”‚ * ยท930563a (โŒ‚) โ–บC +โ”‚ * ยท68a2fc3 (โŒ‚) +โ”‚ * ยท984fd1c (โŒ‚) +โ”œโ”€โ•ฏ +* ๐Ÿยท8f0d338 (โŒ‚) โ–บtags/base "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -352,15 +338,14 @@ fn insert_multi_node_segment_below() -> Result<()> { ); snapbox::assert_data_eq!(git_status(&repo)?, snapbox::str![""]); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let a = repo.rev_parse_single("A")?.detach(); let a_selector = editor @@ -375,39 +360,35 @@ fn insert_multi_node_segment_below() -> Result<()> { .select_commit(b_parent) .context("Failed to find parent of commit b in editor graph")?; - let delimiter = mutate::SegmentDelimiter { + let range = selector::StepRange { child: b_selector, parent: b_parent_selector, }; - editor.insert_segment(a_selector, delimiter, mutate::InsertSide::Below)?; + editor.insert_range(a_selector, range, mutate::InsertSide::Below)?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:main[๐ŸŒณ] - โ””โ”€โ”€ ยท4db28a9 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:A - โ”‚ โ””โ”€โ”€ ยท71dfc8f (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:2[2]:B - โ”‚ โ”œโ”€โ”€ ยทa748762 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ ยท62e05ba (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[3]:anon: - โ”‚ โ””โ”€โ”€ ๐Ÿยท8f0d338 (โŒ‚|1) โ–บtags/base - โ”œโ”€โ”€ โ†’:2: (B) - โ””โ”€โ”€ โ–บ:3[1]:C - โ”œโ”€โ”€ ยท930563a (โŒ‚|1) - โ”œโ”€โ”€ ยท68a2fc3 (โŒ‚|1) - โ””โ”€โ”€ ยท984fd1c (โŒ‚|1) - โ””โ”€โ”€ โ†’:4: - +* ๐Ÿ‘‰ยท4db28a9 (โŒ‚) โ–บmain[๐ŸŒณ] +โ”œโ”€โ”ฌโ”€โ•ฎ +* โ”‚ โ”‚ ยท71dfc8f (โŒ‚) โ–บA +โ”œโ”€โ•ฏ โ”‚ +* โ”‚ ยทa748762 (โŒ‚) โ–บB +* โ”‚ ยท62e05ba (โŒ‚) +โ”‚ * ยท930563a (โŒ‚) โ–บC +โ”‚ * ยท68a2fc3 (โŒ‚) +โ”‚ * ยท984fd1c (โŒ‚) +โ”œโ”€โ”€โ”€โ•ฏ +* ๐Ÿยท8f0d338 (โŒ‚) โ–บtags/base "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -455,15 +436,14 @@ fn insert_single_node_segment_above_with_explicit_children() -> Result<()> { ); snapbox::assert_data_eq!(git_status(&repo)?, snapbox::str![""]); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let a = repo.rev_parse_single("A")?.detach(); let a_selector = editor @@ -478,48 +458,42 @@ fn insert_single_node_segment_above_with_explicit_children() -> Result<()> { .select_commit(c) .context("Failed to find commit c in editor graph")?; - let delimiter = mutate::SegmentDelimiter { + let range = selector::StepRange { child: a_selector, parent: a_selector, }; - editor.insert_segment_into( + editor.insert_range_into( b_selector, - delimiter, + range, mutate::InsertSide::Above, - Some(mutate::SomeSelectors::new(vec![c_selector])?), + Some(selector::SomeSelectors::new(vec![c_selector])?), mutate::ParentReparentingOrder::Prepend, )?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:main[๐ŸŒณ] - โ””โ”€โ”€ ยทcca953f (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[2]:A - โ”‚ โ””โ”€โ”€ ยท69221b4 (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ โ–บ:2[3]:B - โ”‚ โ”‚ โ”œโ”€โ”€ ยทa748762 (โŒ‚|1) - โ”‚ โ”‚ โ””โ”€โ”€ ยท62e05ba (โŒ‚|1) - โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:4[4]:anon: - โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท8f0d338 (โŒ‚|1) โ–บtags/base - โ”‚ โ””โ”€โ”€ โ†’:4: - โ”œโ”€โ”€ โ†’:2: (B) - โ””โ”€โ”€ โ–บ:3[1]:C - โ””โ”€โ”€ ยท76e2160 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:5[2]:anon: - โ”‚ โ”œโ”€โ”€ ยท68a2fc3 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ ยท984fd1c (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:4: - โ””โ”€โ”€ โ†’:1: (A) - +* ๐Ÿ‘‰ยทcca953f (โŒ‚) โ–บmain[๐ŸŒณ] +โ”œโ”€โ”ฌโ”€โ•ฎ +โ”‚ โ”‚ * ยท76e2160 (โŒ‚) โ–บC +โ•ญโ”€โ”€โ”€โ”ค +โ”‚ โ”‚ * ยท68a2fc3 (โŒ‚) +โ”‚ โ”‚ * ยท984fd1c (โŒ‚) +* โ”‚ โ”‚ ยท69221b4 (โŒ‚) โ–บA +โ•ฐโ”€โ”ฌโ”€โ•ฎ + * โ”‚ ยทa748762 (โŒ‚) โ–บB + * โ”‚ ยท62e05ba (โŒ‚) + โ”œโ”€โ•ฏ + * ๐Ÿยท8f0d338 (โŒ‚) โ–บtags/base "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -573,15 +547,14 @@ fn insert_single_node_segment_below_with_explicit_parents() -> Result<()> { ); snapbox::assert_data_eq!(git_status(&repo)?, snapbox::str![""]); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let a = repo.rev_parse_single("A")?.detach(); let a_selector = editor @@ -596,48 +569,43 @@ fn insert_single_node_segment_below_with_explicit_parents() -> Result<()> { .select_commit(c) .context("Failed to find commit c in editor graph")?; - let delimiter = mutate::SegmentDelimiter { + let range = selector::StepRange { child: b_selector, parent: b_selector, }; - editor.insert_segment_into( + editor.insert_range_into( a_selector, - delimiter, + range, mutate::InsertSide::Below, - Some(mutate::SomeSelectors::new(vec![c_selector])?), + Some(selector::SomeSelectors::new(vec![c_selector])?), mutate::ParentReparentingOrder::Prepend, )?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:main[๐ŸŒณ] - โ””โ”€โ”€ ยท54f9cab (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:A - โ”‚ โ””โ”€โ”€ ยท9501727 (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ โ–บ:4[4]:anon: - โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท8f0d338 (โŒ‚|1) โ–บtags/base - โ”‚ โ””โ”€โ”€ โ–บ:2[2]:B - โ”‚ โ””โ”€โ”€ ยท347772f (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ โ–บ:3[3]:C - โ”‚ โ”‚ โ”œโ”€โ”€ ยท930563a (โŒ‚|1) - โ”‚ โ”‚ โ”œโ”€โ”€ ยท68a2fc3 (โŒ‚|1) - โ”‚ โ”‚ โ””โ”€โ”€ ยท984fd1c (โŒ‚|1) - โ”‚ โ”‚ โ””โ”€โ”€ โ†’:4: - โ”‚ โ””โ”€โ”€ โ–บ:5[3]:anon: - โ”‚ โ””โ”€โ”€ ยท62e05ba (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:4: - โ”œโ”€โ”€ โ†’:2: (B) - โ””โ”€โ”€ โ†’:3: (C) - +* ๐Ÿ‘‰ยท54f9cab (โŒ‚) โ–บmain[๐ŸŒณ] +โ”œโ”€โ”ฌโ”€โ•ฎ +* โ”‚ โ”‚ ยท9501727 (โŒ‚) โ–บA +โ”œโ”€โ•ฎ โ”‚ +โ”‚ * โ”‚ ยท347772f (โŒ‚) โ–บB +โ”‚ โ”œโ”€โ•ฎ +โ”‚ โ”‚ * ยท930563a (โŒ‚) โ–บC +โ”‚ โ”‚ * ยท68a2fc3 (โŒ‚) +โ”‚ โ”‚ * ยท984fd1c (โŒ‚) +โ”œโ”€โ”€โ”€โ•ฏ +โ”‚ * ยท62e05ba (โŒ‚) +โ”œโ”€โ•ฏ +* ๐Ÿยท8f0d338 (โŒ‚) โ–บtags/base "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); assert_eq!( parent_subjects(&repo, "B")?, [ @@ -677,15 +645,14 @@ fn insert_single_node_segment_below_with_explicit_parents() -> Result<()> { #[test] fn insert_single_node_segment_below_can_append_reparented_parent() -> Result<()> { let (repo, _tmp, mut meta) = fixture_writable("three-branches-merged")?; - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let a = repo.rev_parse_single("A")?.detach(); let a_selector = editor @@ -700,16 +667,16 @@ fn insert_single_node_segment_below_can_append_reparented_parent() -> Result<()> .select_commit(c) .context("Failed to find commit c in editor graph")?; - let delimiter = mutate::SegmentDelimiter { + let range = selector::StepRange { child: b_selector, parent: b_selector, }; - editor.insert_segment_into( + editor.insert_range_into( a_selector, - delimiter, + range, mutate::InsertSide::Below, - Some(mutate::SomeSelectors::new(vec![c_selector])?), + Some(selector::SomeSelectors::new(vec![c_selector])?), mutate::ParentReparentingOrder::Append, )?; diff --git a/crates/but-rebase/tests/rebase/graph_rebase/materialize.rs b/crates/but-rebase/tests/rebase/graph_rebase/materialize.rs index 8869107e2e8..f50493216fc 100644 --- a/crates/but-rebase/tests/rebase/graph_rebase/materialize.rs +++ b/crates/but-rebase/tests/rebase/graph_rebase/materialize.rs @@ -1,11 +1,11 @@ //! Tests for `materialize` vs `materialize_without_checkout` behavior differences use anyhow::Result; -use but_graph::Graph; +use but_graph::Workspace; use but_rebase::graph_rebase::{Editor, Step}; use but_testsupport::{ - StackState, graph_tree, visualize_commit_graph_all, visualize_disk_tree_skip_dot_git, + StackState, graph_dag, visualize_commit_graph_all, visualize_disk_tree_skip_dot_git, }; -use snapbox::IntoData; +use snapbox::prelude::*; use crate::{ graph_rebase::add_stack_with_segments, @@ -51,10 +51,9 @@ fn materialize_removes_dropped_commit_changes_from_worktree() -> Result<()> { "#]] ); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; // Drop the 'c' commit (HEAD) let c = repo.rev_parse_single("HEAD")?; @@ -62,20 +61,19 @@ fn materialize_removes_dropped_commit_changes_from_worktree() -> Result<()> { editor.replace(c_sel, Step::None)?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:main[๐ŸŒณ] - โ”œโ”€โ”€ ยทa96434e (โŒ‚|1) - โ”œโ”€โ”€ ยทd591dfe (โŒ‚|1) - โ””โ”€โ”€ ๐Ÿยท35b8235 (โŒ‚|1) - +* ๐Ÿ‘‰ยทa96434e (โŒ‚) โ–บmain[๐ŸŒณ] +* ยทd591dfe (โŒ‚) +* ๐Ÿยท35b8235 (โŒ‚) "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); // After materialize, file 'c' should be GONE from worktree snapbox::assert_data_eq!( @@ -132,10 +130,9 @@ fn materialize_without_checkout_preserves_dropped_commit_changes_in_worktree() - "#]] ); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; // Drop the 'c' commit (HEAD) let c = repo.rev_parse_single("HEAD")?; @@ -143,20 +140,19 @@ fn materialize_without_checkout_preserves_dropped_commit_changes_in_worktree() - editor.replace(c_sel, Step::None)?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:main[๐ŸŒณ] - โ”œโ”€โ”€ ยทa96434e (โŒ‚|1) - โ”œโ”€โ”€ ยทd591dfe (โŒ‚|1) - โ””โ”€โ”€ ๐Ÿยท35b8235 (โŒ‚|1) - +* ๐Ÿ‘‰ยทa96434e (โŒ‚) โ–บmain[๐ŸŒณ] +* ยทd591dfe (โŒ‚) +* ๐Ÿยท35b8235 (โŒ‚) "#]] ); let outcome = outcome.materialize_without_checkout()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); // After materialize_without_checkout, file 'c' should STILL exist in worktree snapbox::assert_data_eq!( @@ -192,19 +188,20 @@ fn both_methods_update_references_identically() -> Result<()> { let (ref_after_materialize, overlayed_materialize) = { let (repo, _tmpdir, mut meta) = fixture_writable("four-commits")?; - let graph = Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + let mut ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let c = repo.rev_parse_single("HEAD")?; let c_sel = editor.select_commit(c.detach())?; editor.replace(c_sel, Step::None)?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); ( repo.rev_parse_single("main")?.detach().to_string(), @@ -216,19 +213,20 @@ fn both_methods_update_references_identically() -> Result<()> { let (ref_after_materialize_without_checkout, overlayed_without_checkout) = { let (repo, _tmpdir, mut meta) = fixture_writable("four-commits")?; - let graph = Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + let mut ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let c = repo.rev_parse_single("HEAD")?; let c_sel = editor.select_commit(c.detach())?; editor.replace(c_sel, Step::None)?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); let outcome = outcome.materialize_without_checkout()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); ( repo.rev_parse_single("main")?.detach().to_string(), @@ -237,14 +235,11 @@ fn both_methods_update_references_identically() -> Result<()> { }; snapbox::assert_data_eq!( - &overlayed_materialize, + overlayed_materialize.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:main[๐ŸŒณ] - โ”œโ”€โ”€ ยทa96434e (โŒ‚|1) - โ”œโ”€โ”€ ยทd591dfe (โŒ‚|1) - โ””โ”€โ”€ ๐Ÿยท35b8235 (โŒ‚|1) - +* ๐Ÿ‘‰ยทa96434e (โŒ‚) โ–บmain[๐ŸŒณ] +* ยทd591dfe (โŒ‚) +* ๐Ÿยท35b8235 (โŒ‚) "#]] ); assert_eq!(overlayed_materialize, overlayed_without_checkout); @@ -269,26 +264,23 @@ fn materialize_repoints_head_when_checkout_reference_is_replaced() -> Result<()> let replacement_ref = gix::refs::FullName::try_from("refs/heads/replacement")?; let head_before = repo.rev_parse_single("HEAD")?.detach(); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let main_selector = editor.select_reference("refs/heads/main".try_into()?)?; editor.replace(main_selector, Step::new_reference(replacement_ref.clone()))?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:replacement[๐ŸŒณ] - โ”œโ”€โ”€ ยท120e3a9 (โŒ‚|1) - โ”œโ”€โ”€ ยทa96434e (โŒ‚|1) - โ”œโ”€โ”€ ยทd591dfe (โŒ‚|1) - โ””โ”€โ”€ ๐Ÿยท35b8235 (โŒ‚|1) - +* ๐Ÿ‘‰ยท120e3a9 (โŒ‚) โ–บreplacement[๐ŸŒณ] +* ยทa96434e (โŒ‚) +* ยทd591dfe (โŒ‚) +* ๐Ÿยท35b8235 (โŒ‚) "#]] ); assert_eq!( @@ -298,7 +290,8 @@ fn materialize_repoints_head_when_checkout_reference_is_replaced() -> Result<()> ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); assert_eq!( repo.head_name()?, Some(replacement_ref.clone()), @@ -323,10 +316,9 @@ fn materialize_without_checkout_does_not_repoint_head_when_checkout_reference_is let (repo, _tmpdir, mut meta) = fixture_writable("four-commits")?; let replacement_ref = gix::refs::FullName::try_from("refs/heads/replacement")?; - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let main_selector = editor.select_reference("refs/heads/main".try_into()?)?; editor.replace(main_selector, Step::new_reference(replacement_ref.clone()))?; @@ -376,10 +368,9 @@ fn materialize_keeps_immutable_refs_unchanged_while_updating_local_refs() -> Res .raw() ); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let stack_tip = repo.rev_parse_single("stack-2")?.detach(); let stack_tip_sel = editor.select_commit(stack_tip)?; @@ -411,21 +402,31 @@ fn materialize_keeps_immutable_refs_unchanged_while_updating_local_refs() -> Res Ok(()) } +/// Removing an immutable ref fails at the op layer (loudly, not session-only), and the +/// ref survives on disk. Applies to any immutable ref, not just remote-tracking ones โ€” +/// here an off-walk local `main`. #[test] -fn materialize_does_not_delete_immutable_refs_removed_from_graph() -> Result<()> { +fn removing_an_immutable_ref_fails_and_disk_is_untouched() -> Result<()> { let (repo, _tmpdir, mut meta) = fixture_writable("workspace-with-empty-stack")?; add_stack_with_segments(&mut meta, 1, "stack-1", StackState::InWorkspace, &[]); add_stack_with_segments(&mut meta, 2, "stack-2", StackState::InWorkspace, &[]); let main_ref = gix::refs::FullName::try_from("refs/heads/main")?; let main_before = repo.rev_parse_single("main")?.detach(); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let ws = Workspace::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let main_sel = editor.select_reference(main_ref.as_ref())?; - editor.replace(main_sel, Step::None)?; + snapbox::assert_data_eq!( + editor + .replace(main_sel, Step::None) + .unwrap_err() + .to_string(), + snapbox::str![ + "reference refs/heads/main is immutable and cannot be moved, renamed, or deleted" + ] + ); let outcome = editor.rebase()?; outcome.materialize()?; diff --git a/crates/but-rebase/tests/rebase/graph_rebase/merge_commit_changes.rs b/crates/but-rebase/tests/rebase/graph_rebase/merge_commit_changes.rs index 1b38d1d9374..6f12fb2b394 100644 --- a/crates/but-rebase/tests/rebase/graph_rebase/merge_commit_changes.rs +++ b/crates/but-rebase/tests/rebase/graph_rebase/merge_commit_changes.rs @@ -1,7 +1,7 @@ use anyhow::Result; use bstr::ByteSlice as _; use but_core::RepositoryExt; -use but_graph::Graph; +use but_graph::Workspace; use but_rebase::{ commit::DateMode, graph_rebase::{Editor, LookupStep as _, Step}, @@ -34,15 +34,14 @@ fn matches_clean_octopus_merge() -> Result<()> { .raw() ); - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let left_1 = repo.rev_parse_single("left~1")?.detach(); let left_2 = repo.rev_parse_single("left")?.detach(); @@ -84,15 +83,14 @@ fn excludes_unselected_parent_changes() -> Result<()> { .raw() ); - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let a_commit = repo.rev_parse_single("A")?.detach(); let c_commit = repo.rev_parse_single("C")?.detach(); @@ -157,15 +155,14 @@ fn reports_conflicts() -> Result<()> { .raw() ); - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let a_commit = repo.rev_parse_single("A")?.detach(); let b_commit = repo.rev_parse_single("B")?.detach(); @@ -228,15 +225,14 @@ fn stops_folding_after_first_conflict() -> Result<()> { .raw() ); - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let a_commit = repo.rev_parse_single("A")?.detach(); let b_commit = repo.rev_parse_single("B")?.detach(); @@ -290,15 +286,14 @@ fn preserves_noncontiguous_selected_changes() -> Result<()> { .raw() ); - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let a_commit = repo.rev_parse_single("A")?.detach(); let b_commit = repo.rev_parse_single("B~2")?.detach(); @@ -361,15 +356,14 @@ fn preserves_first_selected_commit_tree_while_applying_later_selected_ranges() - .raw() ); - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let d_commit = repo.rev_parse_single("D")?.detach(); let e_commit = repo.rev_parse_single("E")?.detach(); @@ -416,15 +410,14 @@ fn preserves_first_selected_commit_tree_while_applying_later_selected_ranges() - fn planning_preserves_noncontiguous_selected_changes() -> Result<()> { let (repo, mut meta) = fixture("merge-commits-preserve-noncontiguous-selected-changes-visible")?; - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let a_commit = repo.rev_parse_single("A")?.detach(); let b_commit = repo.rev_parse_single("B~2")?.detach(); @@ -477,7 +470,12 @@ fn planning_fixture_graph() -> Result<()> { #[test] fn planning_collapses_contiguous_selected_chain() -> Result<()> { let mut fixture = simplify_fixture()?; - let editor = Editor::create(&mut fixture.ws, &mut *fixture.meta, &fixture.repo)?; + let editor = Editor::create( + fixture.ws.commit_graph(), + fixture.ws.project_meta(), + &mut *fixture.meta, + &fixture.repo, + )?; let plan = editor.plan_commit_changes_for_merge( fixture.base, @@ -497,7 +495,12 @@ left-3 <- base #[test] fn planning_preserves_unrelated_branch_tips() -> Result<()> { let mut fixture = simplify_fixture()?; - let editor = Editor::create(&mut fixture.ws, &mut *fixture.meta, &fixture.repo)?; + let editor = Editor::create( + fixture.ws.commit_graph(), + fixture.ws.project_meta(), + &mut *fixture.meta, + &fixture.repo, + )?; let plan = editor.plan_commit_changes_for_merge( fixture.base, @@ -527,7 +530,12 @@ main-2 <- main-1 #[test] fn planning_deduplicates_and_keeps_order_of_survivors() -> Result<()> { let mut fixture = simplify_fixture()?; - let editor = Editor::create(&mut fixture.ws, &mut *fixture.meta, &fixture.repo)?; + let editor = Editor::create( + fixture.ws.commit_graph(), + fixture.ws.project_meta(), + &mut *fixture.meta, + &fixture.repo, + )?; let plan = editor.plan_commit_changes_for_merge( fixture.base, @@ -558,15 +566,14 @@ main-3 <- main-1 #[test] fn uses_editor_visible_commits_not_only_original_workspace_graph() -> Result<()> { let (repo, _tmp, mut meta) = fixture_writable("four-commits")?; - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let head = repo.rev_parse_single("HEAD")?.detach(); let mut head_commit = editor.find_commit(head)?; @@ -593,15 +600,14 @@ fn uses_editor_visible_commits_not_only_original_workspace_graph() -> Result<()> #[test] fn planning_prunes_subjects_reachable_from_target_first_parent_lineage() -> Result<()> { let (repo, mut meta) = fixture("merge-commits-preserve-anchor-tree-visible")?; - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let d_commit = repo.rev_parse_single("D")?.detach(); let a_commit = repo.rev_parse_single("A")?.detach(); @@ -621,15 +627,14 @@ fn planning_prunes_subjects_reachable_from_target_first_parent_lineage() -> Resu #[test] fn planning_prunes_subjects_reachable_from_target_merge_parent_lineage() -> Result<()> { let (repo, mut meta) = fixture("three-branches-merged")?; - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let merge_commit = repo.rev_parse_single("main")?.detach(); let b_commit = repo.rev_parse_single("B")?.detach(); @@ -649,7 +654,12 @@ fn planning_prunes_subjects_reachable_from_target_merge_parent_lineage() -> Resu #[test] fn planning_prunes_target_ancestors_and_keeps_external_subject_order() -> Result<()> { let mut fixture = simplify_fixture()?; - let editor = Editor::create(&mut fixture.ws, &mut *fixture.meta, &fixture.repo)?; + let editor = Editor::create( + fixture.ws.commit_graph(), + fixture.ws.project_meta(), + &mut *fixture.meta, + &fixture.repo, + )?; let plan = editor.plan_commit_changes_for_merge( fixture.main_3, @@ -678,15 +688,14 @@ left-3 <- left-2 #[test] fn planning_uses_pruned_selected_first_parent_tree_as_base_boundary() -> Result<()> { let (repo, _tmpdir, mut meta) = fixture_writable("two-branches-shared-bottom-two")?; - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let base = repo.rev_parse_single("right~2")?.detach(); let shared = repo.rev_parse_single("right~1")?.detach(); @@ -708,15 +717,14 @@ left: head <- shared #[test] fn planning_works_after_normalizing_chained_editor_mutations() -> Result<()> { let (repo, _tmp, mut meta) = fixture_writable("four-commits")?; - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let head = repo.rev_parse_single("HEAD")?.detach(); let head_parent = repo.rev_parse_single("HEAD~1")?.detach(); @@ -757,14 +765,13 @@ struct SimplifyFixture { fn simplify_fixture() -> Result { let (repo, meta) = fixture("three-branches-three-commits-visible")?; - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let ws = graph.into_workspace()?; let base = repo.rev_parse_single("main~4")?.detach(); let main_2 = repo.rev_parse_single("main~2")?.detach(); diff --git a/crates/but-rebase/tests/rebase/graph_rebase/mod.rs b/crates/but-rebase/tests/rebase/graph_rebase/mod.rs index 669ba097d20..090f8ab3dc8 100644 --- a/crates/but-rebase/tests/rebase/graph_rebase/mod.rs +++ b/crates/but-rebase/tests/rebase/graph_rebase/mod.rs @@ -13,7 +13,7 @@ mod edge; mod editor_creation; mod graph_workspace; mod insert; -mod insert_segment; +mod insert_range; mod materialize; mod merge_commit_changes; mod multiple_operations; diff --git a/crates/but-rebase/tests/rebase/graph_rebase/order_commit_selectors_by_parentage.rs b/crates/but-rebase/tests/rebase/graph_rebase/order_commit_selectors_by_parentage.rs index ae9bad9bdf1..06ba6c2c3b6 100644 --- a/crates/but-rebase/tests/rebase/graph_rebase/order_commit_selectors_by_parentage.rs +++ b/crates/but-rebase/tests/rebase/graph_rebase/order_commit_selectors_by_parentage.rs @@ -1,13 +1,13 @@ use anyhow::Result; -use but_graph::Graph; -use but_rebase::graph_rebase::{Editor, LookupStep, Step, mutate, testing::Testing as _}; +use but_graph::Workspace; +use but_rebase::graph_rebase::{Editor, LookupStep, Step, selector, testing::Testing as _}; use but_testsupport::visualize_commit_graph_all; use snapbox::prelude::*; use crate::utils::{fixture, fixture_writable, standard_options}; fn short_ids( - editor: &Editor<'_, '_, impl but_core::RefMetadata>, + editor: &Editor<'_, impl but_core::RefMetadata>, selectors: &[but_rebase::graph_rebase::Selector], ) -> Result> { selectors @@ -35,15 +35,14 @@ fn trim_trailing_whitespace(input: &str) -> String { fn handles_zero_nodes() -> Result<()> { let (repo, mut meta) = fixture("four-commits")?; - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; snapbox::assert_data_eq!( editor.steps_ascii(), @@ -73,15 +72,14 @@ fn handles_zero_nodes() -> Result<()> { fn handles_one_node() -> Result<()> { let (repo, mut meta) = fixture("single-commit")?; - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; snapbox::assert_data_eq!( editor.steps_ascii(), @@ -111,15 +109,14 @@ fn handles_one_node() -> Result<()> { fn orders_linear_commits_parent_first_for_n_nodes() -> Result<()> { let (repo, mut meta) = fixture("four-commits")?; - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let base = repo.rev_parse_single("HEAD~3")?.detach(); let a = repo.rev_parse_single("HEAD~2")?.detach(); @@ -148,15 +145,14 @@ fn orders_linear_commits_parent_first_for_n_nodes() -> Result<()> { fn orders_disjoint_commits_by_editor_graph_traversal_1() -> Result<()> { let (repo, mut meta) = fixture("three-branches-merged")?; - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let graph = trim_trailing_whitespace(&visualize_commit_graph_all(&repo)?); snapbox::assert_data_eq!( @@ -200,15 +196,14 @@ fn orders_disjoint_commits_by_editor_graph_traversal_1() -> Result<()> { fn orders_disjoint_commits_by_editor_graph_traversal_2() -> Result<()> { let (repo, mut meta) = fixture("three-branches-merged")?; - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let graph = trim_trailing_whitespace(&visualize_commit_graph_all(&repo)?); snapbox::assert_data_eq!( @@ -261,15 +256,14 @@ fn orders_disjoint_commits_by_editor_graph_traversal_2() -> Result<()> { fn orders_disjoint_commits_by_editor_graph_traversal_3() -> Result<()> { let (repo, mut meta) = fixture("three-branches-merged")?; - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let graph = trim_trailing_whitespace(&visualize_commit_graph_all(&repo)?); snapbox::assert_data_eq!( @@ -337,15 +331,14 @@ fn errors_when_selected_commit_is_absent_from_editor_graph() -> Result<()> { "#]] ); - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; snapbox::assert_data_eq!( editor.steps_ascii(), @@ -383,15 +376,14 @@ fn errors_when_selected_commit_is_absent_from_editor_graph() -> Result<()> { fn deduplicates_duplicate_selectors_by_commit_id() -> Result<()> { let (repo, mut meta) = fixture("four-commits")?; - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let a = repo.rev_parse_single("HEAD~2")?.detach(); let b = repo.rev_parse_single("HEAD~1")?.detach(); @@ -418,15 +410,14 @@ fn deduplicates_duplicate_selectors_by_commit_id() -> Result<()> { fn orders_commit_present_in_editor_graph_even_if_workspace_projection_stale() -> Result<()> { let (repo, _tmpdir, mut meta) = fixture_writable("four-commits")?; - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let a = repo.rev_parse_single("HEAD~2")?.detach(); let a_obj = repo.find_commit(a)?; @@ -456,26 +447,25 @@ fn orders_commit_present_in_editor_graph_even_if_workspace_projection_stale() -> fn orders_commit_disconnected_from_checkout_roots_if_still_in_editor_graph() -> Result<()> { let (repo, mut meta) = fixture("four-commits")?; - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let b = repo.rev_parse_single("HEAD~1")?.detach(); let b_selector = editor.select_commit(b)?; - editor.disconnect_segment_from( - mutate::SegmentDelimiter { + editor.disconnect_range_from( + selector::StepRange { child: b_selector, parent: b_selector, }, - mutate::SelectorSet::All, - mutate::SelectorSet::All, + selector::SelectorSet::All, + selector::SelectorSet::All, true, )?; @@ -514,15 +504,14 @@ fn orders_all_commits_in_y_shaped_two_branch_fixture() -> Result<()> { ); let right_ref: gix::refs::FullName = "refs/heads/right".try_into()?; - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let merge = repo.rev_parse_single("HEAD")?.detach(); let left = repo.rev_parse_single("left")?.detach(); @@ -548,13 +537,13 @@ fn orders_all_commits_in_y_shaped_two_branch_fixture() -> Result<()> { // Disconnect the 'right' branch from the merge commit, making it a leaf node, but keeping it in the editor // graph. - editor.disconnect_segment_from( - mutate::SegmentDelimiter { + editor.disconnect_range_from( + selector::StepRange { child: right_ref_selector, parent: right_selector, }, - mutate::SelectorSet::All, - mutate::SelectorSet::None, + selector::SelectorSet::All, + selector::SelectorSet::None, true, )?; diff --git a/crates/but-rebase/tests/rebase/graph_rebase/rebase_identities.rs b/crates/but-rebase/tests/rebase/graph_rebase/rebase_identities.rs index 80f63903115..96704b25d0a 100644 --- a/crates/but-rebase/tests/rebase/graph_rebase/rebase_identities.rs +++ b/crates/but-rebase/tests/rebase/graph_rebase/rebase_identities.rs @@ -2,9 +2,9 @@ //! graphs are returned. use anyhow::Result; -use but_graph::Graph; +use but_graph::Workspace; use but_rebase::graph_rebase::Editor; -use but_testsupport::{graph_tree, graph_workspace, visualize_commit_graph_all}; +use but_testsupport::{graph_dag, graph_workspace, visualize_commit_graph_all}; use snapbox::prelude::*; use crate::utils::{fixture_writable, standard_options}; @@ -15,7 +15,7 @@ fn four_commits() -> Result<()> { let before = visualize_commit_graph_all(&repo)?; snapbox::assert_data_eq!( - &before, + before.as_str(), snapbox::str![[r#" * 120e3a9 (HEAD -> main) c * a96434e b @@ -25,32 +25,29 @@ fn four_commits() -> Result<()> { "#]] ); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - - let mut ws = graph.clone().into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:main[๐ŸŒณ] - โ”œโ”€โ”€ ยท120e3a9 (โŒ‚|1) - โ”œโ”€โ”€ ยทa96434e (โŒ‚|1) - โ”œโ”€โ”€ ยทd591dfe (โŒ‚|1) - โ””โ”€โ”€ ๐Ÿยท35b8235 (โŒ‚|1) - +* ๐Ÿ‘‰ยท120e3a9 (โŒ‚) โ–บmain[๐ŸŒณ] +* ยทa96434e (โŒ‚) +* ยทd591dfe (โŒ‚) +* ๐Ÿยท35b8235 (โŒ‚) "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); assert_eq!(visualize_commit_graph_all(&repo)?, before); snapbox::assert_data_eq!( @@ -70,7 +67,7 @@ fn four_commits_with_short_traversal() -> Result<()> { let before = visualize_commit_graph_all(&repo)?; snapbox::assert_data_eq!( - &before, + before.as_str(), snapbox::str![[r#" * 120e3a9 (HEAD -> main) c * a96434e b @@ -81,14 +78,13 @@ fn four_commits_with_short_traversal() -> Result<()> { ); let options = standard_options().with_hard_limit(4); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), options, )? .validated()?; - let mut ws = graph.clone().into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -104,23 +100,22 @@ fn four_commits_with_short_traversal() -> Result<()> { "#]] ); - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:main[๐ŸŒณ] - โ”œโ”€โ”€ ยท120e3a9 (โŒ‚|1) - โ”œโ”€โ”€ ยทa96434e (โŒ‚|1) - โ”œโ”€โ”€ ยทd591dfe (โŒ‚|1) - โ””โ”€โ”€ ๐Ÿยท35b8235 (โŒ‚|1) - +* ๐Ÿ‘‰ยท120e3a9 (โŒ‚) โ–บmain[๐ŸŒณ] +* ยทa96434e (โŒ‚) +* ยทd591dfe (โŒ‚) +* ๐Ÿยท35b8235 (โŒ‚) "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); assert_eq!(visualize_commit_graph_all(&repo)?, before); snapbox::assert_data_eq!( @@ -140,7 +135,7 @@ fn merge_in_the_middle() -> Result<()> { let before = visualize_commit_graph_all(&repo)?; snapbox::assert_data_eq!( - &before, + before.as_str(), snapbox::str![[r#" * e8ee978 (HEAD -> with-inner-merge) on top of inner merge * 2fc288c Merge branch 'B' into with-inner-merge @@ -154,38 +149,32 @@ fn merge_in_the_middle() -> Result<()> { .raw() ); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - - let mut ws = graph.clone().into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:with-inner-merge[๐ŸŒณ] - โ””โ”€โ”€ ยทe8ee978 (โŒ‚|1) - โ””โ”€โ”€ โ–บ:1[1]:anon: - โ””โ”€โ”€ ยท2fc288c (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:2[2]:A - โ”‚ โ””โ”€โ”€ ยทadd59d2 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[3]:main - โ”‚ โ””โ”€โ”€ ๐Ÿยท8f0d338 (โŒ‚|1) โ–บtags/base - โ””โ”€โ”€ โ–บ:3[2]:B - โ””โ”€โ”€ ยท984fd1c (โŒ‚|1) - โ””โ”€โ”€ โ†’:4: (main) - +* ๐Ÿ‘‰ยทe8ee978 (โŒ‚) โ–บwith-inner-merge[๐ŸŒณ] +* ยท2fc288c (โŒ‚) +โ”œโ”€โ•ฎ +* โ”‚ ยทadd59d2 (โŒ‚) โ–บA +โ”‚ * ยท984fd1c (โŒ‚) โ–บB +โ”œโ”€โ•ฏ +* ๐Ÿยท8f0d338 (โŒ‚) โ–บmain, โ–บtags/base "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); assert_eq!(visualize_commit_graph_all(&repo)?, before); snapbox::assert_data_eq!( @@ -205,7 +194,7 @@ fn three_branches_merged() -> Result<()> { let before = visualize_commit_graph_all(&repo)?; snapbox::assert_data_eq!( - &before, + before.as_str(), snapbox::str![[r#" *-. 1348870 (HEAD -> main) Merge branches 'A', 'B' and 'C' |\ \ @@ -223,42 +212,36 @@ fn three_branches_merged() -> Result<()> { .raw() ); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - - let mut ws = graph.clone().into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:main[๐ŸŒณ] - โ””โ”€โ”€ ยท1348870 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:A - โ”‚ โ””โ”€โ”€ ยทadd59d2 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[2]:anon: - โ”‚ โ””โ”€โ”€ ๐Ÿยท8f0d338 (โŒ‚|1) โ–บtags/base - โ”œโ”€โ”€ โ–บ:2[1]:B - โ”‚ โ”œโ”€โ”€ ยทa748762 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ ยท62e05ba (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:4: - โ””โ”€โ”€ โ–บ:3[1]:C - โ”œโ”€โ”€ ยท930563a (โŒ‚|1) - โ”œโ”€โ”€ ยท68a2fc3 (โŒ‚|1) - โ””โ”€โ”€ ยท984fd1c (โŒ‚|1) - โ””โ”€โ”€ โ†’:4: - +* ๐Ÿ‘‰ยท1348870 (โŒ‚) โ–บmain[๐ŸŒณ] +โ”œโ”€โ”ฌโ”€โ•ฎ +* โ”‚ โ”‚ ยทadd59d2 (โŒ‚) โ–บA +โ”‚ * โ”‚ ยทa748762 (โŒ‚) โ–บB +โ”‚ * โ”‚ ยท62e05ba (โŒ‚) +โ”œโ”€โ•ฏ โ”‚ +โ”‚ * ยท930563a (โŒ‚) โ–บC +โ”‚ * ยท68a2fc3 (โŒ‚) +โ”‚ * ยท984fd1c (โŒ‚) +โ”œโ”€โ”€โ”€โ•ฏ +* ๐Ÿยท8f0d338 (โŒ‚) โ–บtags/base "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); assert_eq!(visualize_commit_graph_all(&repo)?, before); snapbox::assert_data_eq!( diff --git a/crates/but-rebase/tests/rebase/graph_rebase/replace.rs b/crates/but-rebase/tests/rebase/graph_rebase/replace.rs index 9a75106ed4d..d405c7184ec 100644 --- a/crates/but-rebase/tests/rebase/graph_rebase/replace.rs +++ b/crates/but-rebase/tests/rebase/graph_rebase/replace.rs @@ -1,8 +1,8 @@ //! These tests exercise the replace operation. use anyhow::{Context, Result}; -use but_graph::Graph; +use but_graph::Workspace; use but_rebase::graph_rebase::{Editor, Step}; -use but_testsupport::{git_status, graph_tree, visualize_commit_graph_all, visualize_tree}; +use but_testsupport::{git_status, graph_dag, visualize_commit_graph_all, visualize_tree}; use snapbox::prelude::*; use crate::utils::{fixture_writable, standard_options}; @@ -29,16 +29,14 @@ fn reword_a_commit() -> Result<()> { let head_tree = repo.head_tree()?.id; - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; // get the original a let a = repo.rev_parse_single("A")?.detach(); @@ -57,27 +55,23 @@ fn reword_a_commit() -> Result<()> { editor.replace(a_selector, Step::new_pick(a_new))?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:with-inner-merge[๐ŸŒณ] - โ””โ”€โ”€ ยท78aaae2 (โŒ‚|1) - โ””โ”€โ”€ โ–บ:1[1]:anon: - โ””โ”€โ”€ ยท53af95a (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:2[2]:A - โ”‚ โ””โ”€โ”€ ยท6de6b92 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[3]:main - โ”‚ โ””โ”€โ”€ ๐Ÿยท8f0d338 (โŒ‚|1) โ–บtags/base - โ””โ”€โ”€ โ–บ:3[2]:B - โ””โ”€โ”€ ยท984fd1c (โŒ‚|1) - โ””โ”€โ”€ โ†’:4: (main) - +* ๐Ÿ‘‰ยท78aaae2 (โŒ‚) โ–บwith-inner-merge[๐ŸŒณ] +* ยท53af95a (โŒ‚) +โ”œโ”€โ•ฎ +* โ”‚ ยท6de6b92 (โŒ‚) โ–บA +โ”‚ * ยท984fd1c (โŒ‚) โ–บB +โ”œโ”€โ•ฏ +* ๐Ÿยท8f0d338 (โŒ‚) โ–บmain, โ–บtags/base "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); assert_eq!(head_tree, repo.head_tree()?.id); @@ -132,32 +126,36 @@ fn amend_a_commit() -> Result<()> { snapbox::assert_data_eq!(git_status(&repo)?, snapbox::str![""]); let head_tree = repo.head_tree()?.id(); - snapbox::assert_data_eq!(visualize_tree(head_tree).to_string(), snapbox::str![[r#" + snapbox::assert_data_eq!( + visualize_tree(head_tree).to_string(), + snapbox::str![[r#" f766d1f โ”œโ”€โ”€ added-after-with-inner-merge:100644:861be1b "seq 10\n" โ”œโ”€โ”€ file:100644:d78dd4f "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n50\n51\n52\n53\n54\n55\n56\n57\n58\n59\n60\n" โ””โ”€โ”€ new-file:100644:f00c965 "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n" -"#]].raw()); +"#]].raw() + ); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; // get the original a let a = repo.rev_parse_single("A")?; - snapbox::assert_data_eq!(visualize_tree(a).to_string(), snapbox::str![[r#" + snapbox::assert_data_eq!( + visualize_tree(a).to_string(), + snapbox::str![[r#" 0cc630c โ””โ”€โ”€ file:100644:d78dd4f "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n50\n51\n52\n53\n54\n55\n56\n57\n58\n59\n60\n" -"#]].raw()); +"#]].raw() + ); // reword commit a let mut a_obj = but_core::Commit::from_id(a)?; @@ -179,27 +177,23 @@ f766d1f editor.replace(a_selector, Step::new_pick(a_new))?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:with-inner-merge[๐ŸŒณ] - โ””โ”€โ”€ ยทe7221b5 (โŒ‚|1) - โ””โ”€โ”€ โ–บ:1[1]:anon: - โ””โ”€โ”€ ยท8101192 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:2[2]:A - โ”‚ โ””โ”€โ”€ ยทf1905a8 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[3]:main - โ”‚ โ””โ”€โ”€ ๐Ÿยท8f0d338 (โŒ‚|1) โ–บtags/base - โ””โ”€โ”€ โ–บ:3[2]:B - โ””โ”€โ”€ ยท984fd1c (โŒ‚|1) - โ””โ”€โ”€ โ†’:4: (main) - +* ๐Ÿ‘‰ยทe7221b5 (โŒ‚) โ–บwith-inner-merge[๐ŸŒณ] +* ยท8101192 (โŒ‚) +โ”œโ”€โ•ฎ +* โ”‚ ยทf1905a8 (โŒ‚) โ–บA +โ”‚ * ยท984fd1c (โŒ‚) โ–บB +โ”œโ”€โ•ฏ +* ๐Ÿยท8f0d338 (โŒ‚) โ–บmain, โ–บtags/base "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -230,23 +224,29 @@ f766d1f // A should include our extra blob let a = repo.rev_parse_single("A")?; - snapbox::assert_data_eq!(visualize_tree(a).to_string(), snapbox::str![[r#" + snapbox::assert_data_eq!( + visualize_tree(a).to_string(), + snapbox::str![[r#" 0c482d4 โ”œโ”€โ”€ file:100644:d78dd4f "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n50\n51\n52\n53\n54\n55\n56\n57\n58\n59\n60\n" โ””โ”€โ”€ new-file.txt:100644:715faaf "I\'m a new file :D\n" -"#]].raw()); +"#]].raw() + ); // New head tree should also include our extra blob let new_head_tree = repo.head_tree()?.id(); - snapbox::assert_data_eq!(visualize_tree(new_head_tree).to_string(), snapbox::str![[r#" + snapbox::assert_data_eq!( + visualize_tree(new_head_tree).to_string(), + snapbox::str![[r#" 89042ca โ”œโ”€โ”€ added-after-with-inner-merge:100644:861be1b "seq 10\n" โ”œโ”€โ”€ file:100644:d78dd4f "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n50\n51\n52\n53\n54\n55\n56\n57\n58\n59\n60\n" โ”œโ”€โ”€ new-file:100644:f00c965 "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n" โ””โ”€โ”€ new-file.txt:100644:715faaf "I\'m a new file :D\n" -"#]].raw()); +"#]].raw() + ); Ok(()) } diff --git a/crates/but-rebase/tests/rebase/graph_rebase/sha256.rs b/crates/but-rebase/tests/rebase/graph_rebase/sha256.rs index a2c0ee8a0ed..ce890c39c55 100644 --- a/crates/but-rebase/tests/rebase/graph_rebase/sha256.rs +++ b/crates/but-rebase/tests/rebase/graph_rebase/sha256.rs @@ -1,12 +1,12 @@ //! Tests key graph rebase operations against a SHA-256 repository. use anyhow::{Context, Result}; -use but_graph::Graph; +use but_graph::Workspace; use but_rebase::{ commit::DateMode, graph_rebase::{Editor, Step, mutate::InsertSide}, }; -use but_testsupport::{git_status, graph_tree, visualize_commit_graph_all}; +use but_testsupport::{git_status, graph_dag, visualize_commit_graph_all}; use snapbox::prelude::*; use crate::utils::{fixture_writable, standard_options}; @@ -38,15 +38,14 @@ Sha256 .raw() ); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let merge_id = editor.repo().rev_parse_single("HEAD~")?.detach(); let (selector, mut merge_obj) = editor.find_selectable_commit(merge_id)?; @@ -57,28 +56,24 @@ Sha256 editor.insert(selector, Step::new_pick(new_commit), InsertSide::Below)?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:with-inner-merge[๐ŸŒณ] - โ”œโ”€โ”€ ยทd165592 (โŒ‚|1) - โ””โ”€โ”€ ยท526ed5b (โŒ‚|1) - โ””โ”€โ”€ โ–บ:1[1]:anon: - โ””โ”€โ”€ ยทd261f8f (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:2[2]:A - โ”‚ โ””โ”€โ”€ ยท2ff29ff (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[3]:main - โ”‚ โ””โ”€โ”€ ๐Ÿยท8dcf66f (โŒ‚|1) โ–บtags/base - โ””โ”€โ”€ โ–บ:3[2]:B - โ””โ”€โ”€ ยท8f04e4a (โŒ‚|1) - โ””โ”€โ”€ โ†’:4: (main) - +* ๐Ÿ‘‰ยทd165592 (โŒ‚) โ–บwith-inner-merge[๐ŸŒณ] +* ยท526ed5b (โŒ‚) +* ยทd261f8f (โŒ‚) +โ”œโ”€โ•ฎ +* โ”‚ ยท2ff29ff (โŒ‚) โ–บA +โ”‚ * ยท8f04e4a (โŒ‚) โ–บB +โ”œโ”€โ•ฏ +* ๐Ÿยท8dcf66f (โŒ‚) โ–บmain, โ–บtags/base "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -136,15 +131,14 @@ Sha256 .raw() ); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let a = editor.repo().rev_parse_single("A")?.detach(); let (a_selector, mut a_obj) = editor @@ -156,27 +150,23 @@ Sha256 editor.replace(a_selector, Step::new_pick(a_new))?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:with-inner-merge[๐ŸŒณ] - โ””โ”€โ”€ ยทd050214 (โŒ‚|1) - โ””โ”€โ”€ โ–บ:1[1]:anon: - โ””โ”€โ”€ ยท8b1722f (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:2[2]:A - โ”‚ โ””โ”€โ”€ ยท546b14b (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[3]:main - โ”‚ โ””โ”€โ”€ ๐Ÿยท8dcf66f (โŒ‚|1) โ–บtags/base - โ””โ”€โ”€ โ–บ:3[2]:B - โ””โ”€โ”€ ยท8f04e4a (โŒ‚|1) - โ””โ”€โ”€ โ†’:4: (main) - +* ๐Ÿ‘‰ยทd050214 (โŒ‚) โ–บwith-inner-merge[๐ŸŒณ] +* ยท8b1722f (โŒ‚) +โ”œโ”€โ•ฎ +* โ”‚ ยท546b14b (โŒ‚) โ–บA +โ”‚ * ยท8f04e4a (โŒ‚) โ–บB +โ”œโ”€โ•ฏ +* ๐Ÿยท8dcf66f (โŒ‚) โ–บmain, โ–บtags/base "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -233,15 +223,14 @@ Sha256 .raw() ); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let inner_merge = editor.repo().rev_parse_single("HEAD~")?.detach(); let a = editor.repo().rev_parse_single("A")?.detach(); @@ -262,29 +251,26 @@ Sha256 "#]] ); - editor.add_edge(a_selector, b_selector, 1)?; + editor.insert_edge(a_selector, b_selector, 1)?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:with-inner-merge[๐ŸŒณ] - โ”œโ”€โ”€ ยท636f2bd (โŒ‚|1) - โ””โ”€โ”€ ยท93b14a1 (โŒ‚|1) - โ””โ”€โ”€ โ–บ:1[1]:A - โ””โ”€โ”€ ยท9d083f9 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:2[3]:main - โ”‚ โ””โ”€โ”€ ๐Ÿยท8dcf66f (โŒ‚|1) โ–บtags/base - โ””โ”€โ”€ โ–บ:3[2]:B - โ””โ”€โ”€ ยท8f04e4a (โŒ‚|1) - โ””โ”€โ”€ โ†’:2: (main) - +* ๐Ÿ‘‰ยท636f2bd (โŒ‚) โ–บwith-inner-merge[๐ŸŒณ] +* ยท93b14a1 (โŒ‚) +* ยท9d083f9 (โŒ‚) โ–บA +โ”œโ”€โ•ฎ +โ”‚ * ยท8f04e4a (โŒ‚) โ–บB +โ”œโ”€โ•ฏ +* ๐Ÿยท8dcf66f (โŒ‚) โ–บmain, โ–บtags/base "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, diff --git a/crates/but-rebase/tests/rebase/graph_rebase/signing_preferences.rs b/crates/but-rebase/tests/rebase/graph_rebase/signing_preferences.rs index 92851e59609..2f27caaa1f9 100644 --- a/crates/but-rebase/tests/rebase/graph_rebase/signing_preferences.rs +++ b/crates/but-rebase/tests/rebase/graph_rebase/signing_preferences.rs @@ -2,9 +2,9 @@ use anyhow::Result; use but_core::commit::SignCommit; -use but_graph::Graph; +use but_graph::Workspace; use but_rebase::graph_rebase::{Editor, GraphEditorOptions, Pick, Step, cherry_pick::PickMode}; -use but_testsupport::{cat_commit, graph_tree, visualize_commit_graph_all}; +use but_testsupport::{cat_commit, graph_dag, visualize_commit_graph_all}; use crate::utils::{fixture_writable_with_signing, standard_options}; @@ -14,7 +14,7 @@ fn commits_maintain_state_if_not_cherry_picked() -> Result<()> { let before = visualize_commit_graph_all(&repo)?; snapbox::assert_data_eq!( - &before, + before.as_str(), snapbox::str![[r#" * dd72792 (HEAD -> main, c) c * e5aa7b5 (b) b @@ -24,15 +24,14 @@ fn commits_maintain_state_if_not_cherry_picked() -> Result<()> { "#]] ); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; // Modify the "c" commit to no longer be signed let c = repo.rev_parse_single("c")?; @@ -42,24 +41,20 @@ fn commits_maintain_state_if_not_cherry_picked() -> Result<()> { editor.replace(c_sel, Step::Pick(pick))?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:main[๐ŸŒณ] - โ””โ”€โ”€ ยทdd72792 (โŒ‚|1) โ–บc - โ””โ”€โ”€ โ–บ:1[1]:b - โ””โ”€โ”€ ยทe5aa7b5 (โŒ‚|1) - โ””โ”€โ”€ โ–บ:2[2]:a - โ””โ”€โ”€ ยท3bfeb52 (โŒ‚|1) - โ””โ”€โ”€ โ–บ:3[3]:base - โ””โ”€โ”€ ๐Ÿยทb6e2f57 (โŒ‚|1) - +* ๐Ÿ‘‰ยทdd72792 (โŒ‚) โ–บc, โ–บmain[๐ŸŒณ] +* ยทe5aa7b5 (โŒ‚) โ–บb +* ยท3bfeb52 (โŒ‚) โ–บa +* ๐Ÿยทb6e2f57 (โŒ‚) โ–บbase "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); assert_eq!(visualize_commit_graph_all(&repo)?, before); @@ -72,7 +67,7 @@ fn commits_are_signed_by_default() -> Result<()> { let before = visualize_commit_graph_all(&repo)?; snapbox::assert_data_eq!( - &before, + before.as_str(), snapbox::str![[r#" * dd72792 (HEAD -> main, c) c * e5aa7b5 (b) b @@ -82,15 +77,14 @@ fn commits_are_signed_by_default() -> Result<()> { "#]] ); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; // Remove the "b" commit so "c" gets cherry-picked let b = repo.rev_parse_single("b")?; @@ -98,21 +92,19 @@ fn commits_are_signed_by_default() -> Result<()> { editor.replace(b_sel, Step::None)?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:main[๐ŸŒณ] - โ”œโ”€โ”€ ยท06106c2 (โŒ‚|1) โ–บc - โ””โ”€โ”€ ยท3bfeb52 (โŒ‚|1) โ–บa, โ–บb - โ””โ”€โ”€ โ–บ:1[1]:base - โ””โ”€โ”€ ๐Ÿยทb6e2f57 (โŒ‚|1) - +* ๐Ÿ‘‰ยท06106c2 (โŒ‚) โ–บc, โ–บmain[๐ŸŒณ] +* ยท3bfeb52 (โŒ‚) โ–บa, โ–บb +* ๐Ÿยทb6e2f57 (โŒ‚) โ–บbase "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -162,7 +154,7 @@ fn when_cherry_picking_dont_resign_if_not_set() -> Result<()> { let before = visualize_commit_graph_all(&repo)?; snapbox::assert_data_eq!( - &before, + before.as_str(), snapbox::str![[r#" * dd72792 (HEAD -> main, c) c * e5aa7b5 (b) b @@ -172,15 +164,14 @@ fn when_cherry_picking_dont_resign_if_not_set() -> Result<()> { "#]] ); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; // Modify the "c" commit to no longer be signed let c = repo.rev_parse_single("c")?; @@ -195,21 +186,19 @@ fn when_cherry_picking_dont_resign_if_not_set() -> Result<()> { editor.replace(b_sel, Step::None)?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:main[๐ŸŒณ] - โ”œโ”€โ”€ ยทa773b84 (โŒ‚|1) โ–บc - โ””โ”€โ”€ ยท3bfeb52 (โŒ‚|1) โ–บa, โ–บb - โ””โ”€โ”€ โ–บ:1[1]:base - โ””โ”€โ”€ ๐Ÿยทb6e2f57 (โŒ‚|1) - +* ๐Ÿ‘‰ยทa773b84 (โŒ‚) โ–บc, โ–บmain[๐ŸŒณ] +* ยท3bfeb52 (โŒ‚) โ–บa, โ–บb +* ๐Ÿยทb6e2f57 (โŒ‚) โ–บbase "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -249,7 +238,7 @@ fn force_picked_commit_with_sign_yes_is_signed_when_otherwise_unchanged() -> Res let before = visualize_commit_graph_all(&repo)?; snapbox::assert_data_eq!( - &before, + before.as_str(), snapbox::str![[r#" * ea8caac (HEAD -> main, top) top * 135e6ba (mid) mid @@ -258,16 +247,16 @@ fn force_picked_commit_with_sign_yes_is_signed_when_otherwise_unchanged() -> Res "#]] ); - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; let mut editor = Editor::create_with_opts( - &mut ws, + ws.commit_graph(), + ws.project_meta(), &mut *meta, &repo, &GraphEditorOptions { @@ -331,7 +320,7 @@ fn force_picked_ancestor_does_not_sign_descendants_picked_with_sign_commit_no() let before = visualize_commit_graph_all(&repo)?; snapbox::assert_data_eq!( - &before, + before.as_str(), snapbox::str![[r#" * ea8caac (HEAD -> main, top) top * 135e6ba (mid) mid @@ -340,16 +329,16 @@ fn force_picked_ancestor_does_not_sign_descendants_picked_with_sign_commit_no() "#]] ); - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; let mut editor = Editor::create_with_opts( - &mut ws, + ws.commit_graph(), + ws.project_meta(), &mut *meta, &repo, &GraphEditorOptions { @@ -432,7 +421,7 @@ fn force_picked_ancestor_triggers_cascading_signatures_on_descendants_picked_wit let before = visualize_commit_graph_all(&repo)?; snapbox::assert_data_eq!( - &before, + before.as_str(), snapbox::str![[r#" * ea8caac (HEAD -> main, top) top * 135e6ba (mid) mid @@ -441,16 +430,16 @@ fn force_picked_ancestor_triggers_cascading_signatures_on_descendants_picked_wit "#]] ); - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; let mut editor = Editor::create_with_opts( - &mut ws, + ws.commit_graph(), + ws.project_meta(), &mut *meta, &repo, &GraphEditorOptions { @@ -530,7 +519,7 @@ fn commit_picked_with_sign_if_enabled_is_not_signed_when_signing_config_is_disab let before = visualize_commit_graph_all(&repo)?; snapbox::assert_data_eq!( - &before, + before.as_str(), snapbox::str![[r#" * ea8caac (HEAD -> main, top) top * 135e6ba (mid) mid @@ -539,17 +528,17 @@ fn commit_picked_with_sign_if_enabled_is_not_signed_when_signing_config_is_disab "#]] ); - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; let mut editor = Editor::create_with_opts( - &mut ws, + ws.commit_graph(), + ws.project_meta(), &mut *meta, &repo, &GraphEditorOptions { @@ -612,7 +601,7 @@ fn parentless_commit_force_picked_with_sign_yes_is_signed() -> Result<()> { let before = visualize_commit_graph_all(&repo)?; snapbox::assert_data_eq!( - &before, + before.as_str(), snapbox::str![[r#" * ea8caac (HEAD -> main, top) top * 135e6ba (mid) mid @@ -621,17 +610,17 @@ fn parentless_commit_force_picked_with_sign_yes_is_signed() -> Result<()> { "#]] ); - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; let mut editor = Editor::create_with_opts( - &mut ws, + ws.commit_graph(), + ws.project_meta(), &mut *meta, &repo, &GraphEditorOptions { diff --git a/crates/but-rebase/tests/rebase/graph_rebase/workspace_commit_behaviour.rs b/crates/but-rebase/tests/rebase/graph_rebase/workspace_commit_behaviour.rs index bb13a1e1c6f..e93e0ec828d 100644 --- a/crates/but-rebase/tests/rebase/graph_rebase/workspace_commit_behaviour.rs +++ b/crates/but-rebase/tests/rebase/graph_rebase/workspace_commit_behaviour.rs @@ -1,9 +1,9 @@ //! These tests cover behaviour specific to the workspace commit use anyhow::Result; -use but_graph::Graph; +use but_graph::Workspace; use but_rebase::graph_rebase::{Editor, LookupStep, Pick, Step}; -use but_testsupport::{cat_commit, graph_tree, visualize_commit_graph_all}; +use but_testsupport::{cat_commit, graph_dag, visualize_commit_graph_all}; use snapbox::prelude::*; use crate::{ @@ -17,7 +17,7 @@ fn workspace_remains_unchanged_with_no_operations() -> Result<()> { let before = visualize_commit_graph_all(&repo)?; snapbox::assert_data_eq!( - &before, + before.as_str(), snapbox::str![[r#" * 8795f47 (HEAD -> gitbutler/workspace) GitButler Workspace Commit * dd72792 (main, c) c @@ -28,16 +28,14 @@ fn workspace_remains_unchanged_with_no_operations() -> Result<()> { "#]] ); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let id = repo.rev_parse_single("gitbutler/workspace")?; let selector = editor.select_commit(id.detach())?; @@ -50,21 +48,18 @@ fn workspace_remains_unchanged_with_no_operations() -> Result<()> { ); let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] - โ”œโ”€โ”€ ยท8795f47 (โŒ‚|1) - โ””โ”€โ”€ ยทdd72792 (โŒ‚|1) โ–บc, โ–บmain - โ””โ”€โ”€ โ–บ:1[1]:b - โ””โ”€โ”€ ยทe5aa7b5 (โŒ‚|1) - โ””โ”€โ”€ โ–บ:2[2]:a - โ””โ”€โ”€ ยท3bfeb52 (โŒ‚|1) - โ””โ”€โ”€ โ–บ:3[3]:base - โ””โ”€โ”€ ๐Ÿยทb6e2f57 (โŒ‚|1) - +* ๐Ÿ‘‰ยท8795f47 (โŒ‚) +* ยทdd72792 (โŒ‚) โ–บc, โ–บmain +* ยทe5aa7b5 (โŒ‚) โ–บb +* ยท3bfeb52 (โŒ‚) โ–บa +* ๐Ÿยทb6e2f57 (โŒ‚) โ–บbase +layout: + materialized parents: 8795f47: dd72792 "#]] ); @@ -76,10 +71,8 @@ fn workspace_remains_unchanged_with_no_operations() -> Result<()> { ); let mat_outcome = outcome.materialize()?; - assert_eq!( - overlayed, - graph_tree(&mat_outcome.workspace.graph).to_string() - ); + ws.refresh_from_commit_graph(mat_outcome.arena().clone(), &repo, mat_outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); let step = mat_outcome.lookup_step(selector)?; assert_eq!( @@ -99,7 +92,7 @@ fn workspace_commit_is_not_signed_after_cherry_pick() -> Result<()> { let before = visualize_commit_graph_all(&repo)?; snapbox::assert_data_eq!( - &before, + before.as_str(), snapbox::str![[r#" * 8795f47 (HEAD -> gitbutler/workspace) GitButler Workspace Commit * dd72792 (main, c) c @@ -110,15 +103,14 @@ fn workspace_commit_is_not_signed_after_cherry_pick() -> Result<()> { "#]] ); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; // Remove the "b" commit so "c" and the workspace commit get cherry-picked let b = repo.rev_parse_single("b")?; @@ -126,22 +118,22 @@ fn workspace_commit_is_not_signed_after_cherry_pick() -> Result<()> { editor.replace(b_sel, Step::None)?; let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] - โ”œโ”€โ”€ ยทbadca2f (โŒ‚|1) - โ”œโ”€โ”€ ยท06106c2 (โŒ‚|1) โ–บc, โ–บmain - โ””โ”€โ”€ ยท3bfeb52 (โŒ‚|1) โ–บa, โ–บb - โ””โ”€โ”€ โ–บ:1[1]:base - โ””โ”€โ”€ ๐Ÿยทb6e2f57 (โŒ‚|1) - +* ๐Ÿ‘‰ยทbadca2f (โŒ‚) +* ยท06106c2 (โŒ‚) โ–บc, โ–บmain +* ยท3bfeb52 (โŒ‚) โ–บa, โ–บb +* ๐Ÿยทb6e2f57 (โŒ‚) โ–บbase +layout: + materialized parents: badca2f: 06106c2 "#]] ); let outcome = outcome.materialize()?; - assert_eq!(overlayed, graph_tree(&outcome.workspace.graph).to_string()); + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -208,7 +200,7 @@ fn ad_hoc_workspace_keeps_regular_defaults() -> Result<()> { let before = visualize_commit_graph_all(&repo)?; snapbox::assert_data_eq!( - &before, + before.as_str(), snapbox::str![[r#" * 120e3a9 (HEAD -> main) c * a96434e b @@ -218,16 +210,14 @@ fn ad_hoc_workspace_keeps_regular_defaults() -> Result<()> { "#]] ); - let graph = Graph::from_head( + let mut ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; let id = repo.rev_parse_single("HEAD")?; let selector = editor.select_commit(id.detach())?; @@ -240,17 +230,15 @@ fn ad_hoc_workspace_keeps_regular_defaults() -> Result<()> { ); let outcome = editor.rebase()?; - let overlayed = graph_tree(&outcome.overlayed_graph()?).to_string(); + let overlayed = + graph_dag(&ws.redo(outcome.repo(), outcome.meta(), outcome.rebase_overlay()?)?); snapbox::assert_data_eq!( - &overlayed, + overlayed.as_str(), snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:main[๐ŸŒณ] - โ”œโ”€โ”€ ยท120e3a9 (โŒ‚|1) - โ”œโ”€โ”€ ยทa96434e (โŒ‚|1) - โ”œโ”€โ”€ ยทd591dfe (โŒ‚|1) - โ””โ”€โ”€ ๐Ÿยท35b8235 (โŒ‚|1) - +* ๐Ÿ‘‰ยท120e3a9 (โŒ‚) โ–บmain[๐ŸŒณ] +* ยทa96434e (โŒ‚) +* ยทd591dfe (โŒ‚) +* ๐Ÿยท35b8235 (โŒ‚) "#]] ); @@ -262,10 +250,8 @@ fn ad_hoc_workspace_keeps_regular_defaults() -> Result<()> { ); let mat_outcome = outcome.materialize()?; - assert_eq!( - overlayed, - graph_tree(&mat_outcome.workspace.graph).to_string() - ); + ws.refresh_from_commit_graph(mat_outcome.arena().clone(), &repo, mat_outcome.meta)?; + assert_eq!(overlayed, graph_dag(&ws)); let step = mat_outcome.lookup_step(selector)?; assert_eq!( @@ -296,16 +282,14 @@ fn workspace_commit_should_not_be_allowed_to_conflict() -> Result<()> { "#]] ); - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - - let mut ws = graph.into_workspace()?; - let mut editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; // Dropping c will cause the workspace commit to conflict because the WC // depends on a file created in c @@ -395,16 +379,14 @@ fn workspace_commit_with_deleted_branch_ref_rebases_successfully() -> Result<()> .raw() ); - let graph = Graph::from_head( + let ws = Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), standard_options(), )? .validated()?; - - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut *meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; // The rebase should succeed even though the workspace commit has a // parent that no longer has a corresponding Reference node. diff --git a/crates/but-rebase/tests/rebase/main.rs b/crates/but-rebase/tests/rebase/main.rs index 8d42dc24467..af87cd7d791 100644 --- a/crates/but-rebase/tests/rebase/main.rs +++ b/crates/but-rebase/tests/rebase/main.rs @@ -931,14 +931,13 @@ pub mod utils { .collect() } - pub fn standard_options() -> but_graph::init::Options { - but_graph::init::Options { + pub fn standard_options() -> but_graph::walk::Options { + but_graph::walk::Options { collect_tags: true, commits_limit_hint: None, commits_limit_recharge_location: vec![], hard_limit: None, extra_target_commit_id: None, - dangerously_skip_postprocessing_for_debugging: false, } } } diff --git a/crates/but-testsupport/Cargo.toml b/crates/but-testsupport/Cargo.toml index 864fc127902..723c2334d65 100644 --- a/crates/but-testsupport/Cargo.toml +++ b/crates/but-testsupport/Cargo.toml @@ -6,6 +6,12 @@ authors.workspace = true publish = false rust-version.workspace = true +[package.metadata.cargo-machete] +ignored = [ + # Imported as `renderdag` (the crate's lib name), which machete misses. + "sapling-renderdag", +] + [lib] # Unit-tests are disabled as there are none right now. test = false @@ -41,6 +47,7 @@ gix.workspace = true anyhow.workspace = true temp-env = "0.3" termtree = "0.5.1" +sapling-renderdag.workspace = true regex = { workspace = true } snapbox = { workspace = true, optional = true, features = ["regex"] } diff --git a/crates/but-testsupport/src/graph.rs b/crates/but-testsupport/src/graph.rs index 897e7696d9d..8cd75c16024 100644 --- a/crates/but-testsupport/src/graph.rs +++ b/crates/but-testsupport/src/graph.rs @@ -1,7 +1,7 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; use but_core::ref_metadata::StackId; -use but_graph::{Graph, SegmentIndex, SegmentMetadata, workspace::StackCommitDebugFlags}; +use but_graph::workspace::StackCommitDebugFlags; use termtree::Tree; type StringTree = Tree; @@ -20,7 +20,7 @@ fn graph_workspace_inner( workspace: &but_graph::Workspace, mut stack_id_map: Option>, ) -> StringTree { - let commit_flags = if workspace.graph.hard_limit_hit() { + let commit_flags = if workspace.hard_limit_hit() { StackCommitDebugFlags::HardLimitReached } else { Default::default() @@ -28,7 +28,7 @@ fn graph_workspace_inner( let mut root = Tree::new(workspace.debug_string()); for stack in &workspace.stacks { root.push(tree_for_stack( - &workspace.graph, + workspace, stack, commit_flags, stack_id_map.as_mut(), @@ -38,30 +38,30 @@ fn graph_workspace_inner( } fn tree_for_stack( - graph: &Graph, + ws: &but_graph::Workspace, stack: &but_graph::workspace::Stack, commit_flags: StackCommitDebugFlags, stack_id_map: Option<&mut BTreeMap>, ) -> StringTree { let mut root = Tree::new(stack.debug_string_with_graph_context( - graph, + ws, stack.id.zip(stack_id_map).map(|(id, map)| { let next_id = StackId::from_number_for_testing((map.len() + 1) as u128); *map.entry(id).or_insert(next_id) }), )); for segment in &stack.segments { - root.push(tree_for_stack_segment(graph, segment, commit_flags)); + root.push(tree_for_stack_segment(ws, segment, commit_flags)); } root } fn tree_for_stack_segment( - graph: &Graph, + ws: &but_graph::Workspace, segment: &but_graph::workspace::StackSegment, commit_flags: StackCommitDebugFlags, ) -> StringTree { - let mut root = Tree::new(segment.debug_string_with_graph_context(graph)); + let mut root = Tree::new(segment.debug_string_with_graph_context(ws)); if let Some(outside) = &segment.commits_outside { for commit in outside { root.push(format!("{}*", commit.debug_string(commit_flags))); @@ -76,168 +76,149 @@ fn tree_for_stack_segment( root } -/// Visualize `graph` as a tree. -pub fn graph_tree(graph: &Graph) -> StringTree { - let mut root = Tree::new("".to_string()); - let mut seen = Default::default(); - let max_goals = graph.max_goals(); - for sidx in graph.tip_segments() { - root.push(recurse_segment(graph, sidx, &mut seen, max_goals)); - } - let missing = graph.num_segments() - seen.len(); - if missing > 0 { - let mut missing = Tree::new(format!( - "ERROR: disconnected {missing} nodes unreachable through base" - )); - let mut newly_seen = Default::default(); - for sidx in graph.segments().filter(|sidx| !seen.contains(sidx)) { - missing.push(recurse_segment(graph, sidx, &mut newly_seen, max_goals)); - } - root.push(missing); - seen.extend(newly_seen); - } +/// Visualize `graph` as a commit DAG over its carried commit graph, with a `layout:` +/// footer for the metadata-driven ref placements. Local refs with a derived +/// remote-tracking branch show it as ` <> remote`. +pub fn graph_dag(ws: &but_graph::Workspace) -> String { + use renderdag::{Ancestor, GraphRowRenderer, Renderer as _}; - if seen.is_empty() { - "".to_string().into() - } else { - root + let cg = ws.commit_graph(); + let ids: Vec = cg.commit_ids().collect(); + if ids.is_empty() { + let mut out = "".to_string(); + if let Some(ref_name) = cg.entrypoint_ref() { + out.push_str(&format!(" ๐Ÿ‘‰โ–บ{}", ref_name.as_ref().shorten())); + } + return out; } -} -fn tree_for_commit( - graph: &Graph, - commit: &but_graph::Commit, - is_entrypoint: bool, - stop_condition: Option, - hard_limit_hit: bool, - max_goals: Option, -) -> StringTree { - graph - .commit_debug_string_with_graph_context( - commit, - is_entrypoint, - stop_condition, - hard_limit_hit, - max_goals, - ) - .into() -} -fn recurse_segment( - graph: &but_graph::Graph, - sidx: SegmentIndex, - seen: &mut BTreeSet, - max_goals: Option, -) -> StringTree { - let segment = &graph[sidx]; - if seen.contains(&sidx) { - return format!( - "โ†’:{sidx}:{name}", - sidx = sidx.index(), - name = graph[sidx] - .ref_info - .as_ref() - .map(|ri| format!( - " ({}{maybe_sibling})", - graph.ref_debug_string_with_graph_context( - ri.ref_name.as_ref(), - ri.worktree.as_ref(), - ), - maybe_sibling = segment - .remote_tracking_branch_segment_id - .or(segment.sibling_segment_id) - .map_or_else(String::new, |sid| format!(" โ†’:{}:", sid.index())) - )) - .unwrap_or_default() - ) - .into(); - } - seen.insert(sidx); - let ep = graph.entrypoint().unwrap(); - let segment_is_entrypoint = ep.segment.id == sidx; - let entrypoint_commit = ep.commit(); - let mut entrypoint_commit_index = - entrypoint_commit.and_then(|commit| ep.segment.commit_index_of(commit.id)); - if ep.segment.ref_info.is_some() && entrypoint_commit_index == Some(0) { - entrypoint_commit_index = None; - } - let mut show_segment_entrypoint = segment_is_entrypoint; - if segment_is_entrypoint { - // Reduce noise by preferring ref-based entry-points. - if segment.ref_info.is_none() && entrypoint_commit_index.is_some() { - show_segment_entrypoint = false; + // git-log style topo order: every commit after all its children; tips seed the + // stack in the walk's seeding order. + let mut indegree: BTreeMap = ids.iter().map(|&id| (id, 0)).collect(); + for &id in &ids { + for parent in cg.connected_parents(id) { + *indegree.get_mut(&parent).expect("parent iterated above") += 1; } } - let connected_segments = { - let mut m = BTreeMap::<_, Vec<_>>::new(); - let below = graph.segments_below_in_order(sidx).collect::>(); - for (source_cidx, sidx) in below { - m.entry(source_cidx).or_default().push(sidx); + let mut stack: Vec = ids + .iter() + .rev() + .filter(|id| indegree[*id] == 0) + .copied() + .collect(); + let mut order = Vec::with_capacity(ids.len()); + while let Some(id) = stack.pop() { + order.push(id); + let parents: Vec<_> = cg.connected_parents(id).collect(); + for parent in parents.iter().rev() { + let d = indegree.get_mut(parent).expect("known id"); + *d -= 1; + if *d == 0 { + stack.push(*parent); + } } - m - }; + } - let mut root = Tree::new(format!( - "{entrypoint}{meta}{arrow}:{id}[{generation}]:{ref_name_and_remote}", - meta = match segment.metadata { - None => { - "" + // The workspace-level entrypoint exists on every build path, the arena's only on fresh walks. + let entrypoint = ws + .entrypoint_commit_id() + .ok() + .flatten() + .or_else(|| cg.entrypoint()); + let mut renderer = GraphRowRenderer::::new() + .output() + .with_min_row_height(1) + .build_box_drawing(); + let mut out = String::new(); + for id in order { + let commit = cg.node(id).expect("iterating existing ids"); + // Workspace refs are machinery, not commit decoration โ€” filter so walk-built and + // seam-built substrates render identically. + let commit = { + let mut commit = commit.clone(); + commit + .refs + .retain(|ri| !but_core::is_workspace_ref_name(ri.ref_name.as_ref())); + commit + }; + let commit = &commit; + let followed: Vec<_> = cg.connected_parents(id).collect(); + let stop_condition = if followed.is_empty() { + let mut condition = but_graph::StopCondition::empty(); + if commit.parent_ids.is_empty() { + condition |= but_graph::StopCondition::FirstCommit; } - Some(SegmentMetadata::Workspace(_)) => { - "๐Ÿ“•" + if commit + .flags + .contains(but_graph::CommitFlags::ShallowBoundary) + { + condition |= but_graph::StopCondition::ShallowBoundary; } - Some(SegmentMetadata::Branch(_)) => { - "๐Ÿ“™" - } - }, - id = segment.id.index(), - generation = segment.generation, - arrow = if segment.workspace_metadata().is_some() { - "โ–บโ–บโ–บ" - } else { - "โ–บ" - }, - entrypoint = if show_segment_entrypoint { - if entrypoint_commit.is_none() && entrypoint_commit_index.is_some() { - "๐Ÿซฑ" - } else { - "๐Ÿ‘‰" + if !commit.parent_ids.is_empty() + && !condition.contains(but_graph::StopCondition::ShallowBoundary) + { + condition |= but_graph::StopCondition::Limit; } + (!condition.is_empty()).then_some(condition) } else { - "" - }, - ref_name_and_remote = graph.ref_and_remote_debug_string_with_graph_context( - segment.ref_info.as_ref(), - segment.remote_tracking_ref_name.as_ref(), - segment.sibling_segment_id, - segment.remote_tracking_branch_segment_id, - ), - )); - for (cidx, commit) in segment.commits.iter().enumerate() { - let mut commit_tree = tree_for_commit( - graph, - commit, - segment_is_entrypoint && Some(cidx) == entrypoint_commit_index, - if cidx + 1 != segment.commits.len() { - None - } else { - graph.stop_condition(sidx) - }, - graph.hard_limit_hit(), - max_goals, - ); - if let Some(segment_indices) = connected_segments.get(&Some(cidx)) { - for sidx in segment_indices { - commit_tree.push(recurse_segment(graph, *sidx, seen, max_goals)); - } + None + }; + let mut line = ws.commit_debug_string(commit, entrypoint == Some(id), stop_condition); + let remotes: Vec = commit + .refs + .iter() + .filter_map(|ri| ws.remote_tracking_branch(ri.ref_name.as_ref())) + .map(|remote| remote.as_ref().shorten().to_string()) + .collect(); + if !remotes.is_empty() { + line.push_str(&format!(" <> {}", remotes.join(", "))); } - root.push(commit_tree); + out.push_str(&renderer.next_row( + id, + followed.into_iter().map(Ancestor::Parent).collect(), + "*".to_string(), + line, + )); } - // Get the segments that are directly connected. - if let Some(segment_indices) = connected_segments.get(&None) { - for sidx in segment_indices { - root.push(recurse_segment(graph, *sidx, seen, max_goals)); - } + let out = out.trim_end().to_string(); + match layout_footer(ws.commit_graph()) { + Some(footer) => format!("{out}\n{footer}"), + None => out, } +} - root +/// The `layout:` footer of [`graph_dag`]: the stored layout's empty-chain anchors +/// (`^` marks an anchor that joins an owning chain) and the workspace commit's +/// materialized parents. +fn layout_footer(cg: &but_graph::CommitGraph) -> Option { + let layout = cg.layout()?; + let mut lines = Vec::new(); + if let Some(m) = &layout.materialized_ws_parents { + let (ws, parents) = (&m.commit, &m.parents); + lines.push(format!( + " materialized parents: {}: {}", + ws.to_hex_with_len(7), + parents + .iter() + .map(|id| id.to_hex_with_len(7).to_string()) + .collect::>() + .join(" ") + )); + } + if !layout.empty_chain_anchors.is_empty() { + lines.push(format!( + " empty chain anchors: {}", + layout + .empty_chain_anchors + .iter() + .map(|anchor| format!( + "{}{}", + anchor.commit.to_hex_with_len(7), + if anchor.joins_owning_chain { "^" } else { "" } + )) + .collect::>() + .join(" ") + )); + } + (!lines.is_empty()).then(|| format!("layout:\n{}", lines.join("\n"))) } diff --git a/crates/but-testsupport/src/lib.rs b/crates/but-testsupport/src/lib.rs index fa6bbe571f9..2e7e73edb0e 100644 --- a/crates/but-testsupport/src/lib.rs +++ b/crates/but-testsupport/src/lib.rs @@ -697,7 +697,7 @@ pub fn debug_str(input: &dyn std::fmt::Debug) -> String { } mod graph; -pub use graph::{graph_tree, graph_workspace, graph_workspace_determinisitcally}; +pub use graph::{graph_dag, graph_workspace, graph_workspace_determinisitcally}; mod prepare_cmd_env; pub use prepare_cmd_env::{isolate_env_std_cmd, isolate_env_std_cmd_with_additional_removals}; diff --git a/crates/but-testsupport/src/sandbox.rs b/crates/but-testsupport/src/sandbox.rs index 2da34a3be17..f6b13ba9077 100644 --- a/crates/but-testsupport/src/sandbox.rs +++ b/crates/but-testsupport/src/sandbox.rs @@ -262,37 +262,38 @@ impl Sandbox { .unwrap() } - /// Return the graph at `HEAD`, along with the `(graph, repo, meta)` repository and metadata used to create it. - pub fn graph_at_head( + /// Return the workspace at `HEAD`, along with the `(workspace, repo, meta)` repository and + /// metadata used to create it. + pub fn workspace_at_head( &self, ) -> ( - but_graph::Graph, + but_graph::Workspace, gix::Repository, impl but_core::RefMetadata, ) { let repo = self.open_repo(); let meta = self.meta(); - let graph = but_graph::Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &meta, self.project_meta(), - but_graph::init::Options::default(), + but_graph::walk::Options::default(), ) .unwrap(); - (graph, repo, meta) + (ws, repo, meta) } - /// Return a worktree visualisation, freshly read from [Self::graph_at_head()]. + /// Return a worktree visualisation, freshly read from [Self::workspace_at_head()]. pub fn workspace_debug_at_head(&self) -> String { - let (graph, _repo, _meta) = self.graph_at_head(); - graph_workspace_determinisitcally(&graph.into_workspace().unwrap()).to_string() + let (ws, _repo, _meta) = self.workspace_at_head(); + graph_workspace_determinisitcally(&ws).to_string() } /// Open the graph at `HEAD` as SVG for debugging. #[cfg(unix)] pub fn open_graph_at_head_as_svg(&self) { - let (graph, _repo, _meta) = self.graph_at_head(); - graph.open_as_svg(); + let (ws, _repo, _meta) = self.workspace_at_head(); + ws.open_graph_as_svg(); } /// Show a git log for all refs. diff --git a/crates/but-transaction/Cargo.toml b/crates/but-transaction/Cargo.toml index a6d71f26483..162cad7d5da 100644 --- a/crates/but-transaction/Cargo.toml +++ b/crates/but-transaction/Cargo.toml @@ -14,6 +14,7 @@ but-api.workspace = true but-ctx.workspace = true but-oplog.workspace = true but-core.workspace = true +but-graph.workspace = true but-workspace.workspace = true but-rebase.workspace = true but-db.workspace = true diff --git a/crates/but-transaction/src/lib.rs b/crates/but-transaction/src/lib.rs index d53209f4e04..5ba25f37d38 100644 --- a/crates/but-transaction/src/lib.rs +++ b/crates/but-transaction/src/lib.rs @@ -13,8 +13,7 @@ use but_core::{ use but_ctx::Context; use but_oplog::legacy::SnapshotDetails; use but_rebase::graph_rebase::{ - Editor, LookupStep as _, Step, SuccessfulRebase, - mutate::{InsertSide, RelativeTo}, + Editor, LookupStep as _, Step, SuccessfulRebase, mutate::InsertSide, selector::RelativeTo, }; use but_workspace::commit::{ MoveChangesOutcome, SquashCommitsOutcome, squash_commits::MessageCombinationStrategy, @@ -114,11 +113,12 @@ where let db_tx = db.transaction()?; - let editor = Editor::create(&mut ws, meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), meta, &repo)?; let rebase = editor.rebase()?; let mut inner = Inner { rebase: Some(rebase), + workspace: ws.clone(), db_tx, commit_mappings: CommitMappings::default(), pending_metadata_removals: Vec::new(), @@ -143,6 +143,7 @@ where let Inner { mut rebase, + workspace: _, db_tx, commit_mappings: _, pending_metadata_removals, @@ -156,6 +157,7 @@ where let should_rollback = callback_outcome.should_rollback(); let outcome = callback_outcome.maybe_commit( &repo, + &mut ws, rebase, db_tx, pending_metadata_removals, @@ -206,7 +208,9 @@ where { // an Option so we can "take" the rebase, convert it into an editor, perform another rebase, // and put the result back. - rebase: Option>, + rebase: Option>, + // The workspace the transaction started from, used as the base for overlay previews. + workspace: but_graph::Workspace, db_tx: but_db::Transaction<'rebase>, pending_metadata_removals: Vec, pending_metadata_updates: Vec, @@ -334,8 +338,14 @@ where source_branch: &FullNameRef, target_branch: &FullNameRef, ) -> anyhow::Result<()> { + let workspace = self.inner.workspace.clone(); let (ws_meta, new_tip, branch_stack_order) = self.rebase(|editor, _, _| { - let outcome = but_workspace::branch::move_branch(editor, source_branch, target_branch)?; + let outcome = but_workspace::branch::move_branch( + editor, + &workspace, + source_branch, + target_branch, + )?; Ok(( (outcome.ws_meta, outcome.new_tip, outcome.branch_stack_order), outcome.rebase, @@ -353,8 +363,10 @@ where } pub fn tear_off_branch(&mut self, source_branch: &FullNameRef) -> anyhow::Result<()> { + let workspace = self.inner.workspace.clone(); let ws_meta = self.rebase(|editor, _, _| { - let outcome = but_workspace::branch::tear_off_branch(editor, source_branch, None)?; + let outcome = + but_workspace::branch::tear_off_branch(editor, &workspace, source_branch, None)?; Ok((outcome.ws_meta, outcome.rebase)) })?; @@ -371,20 +383,20 @@ where return Ok(()); }; - let workspace = self - .inner - .rebase - .as_ref() - .expect("rebase is always Some(_)") - .overlayed_graph()? - .into_workspace()?; + let workspace = but_workspace::workspace::overlayed_workspace( + &self.inner.workspace, + self.inner + .rebase + .as_ref() + .expect("rebase is always Some(_)"), + )?; let ref_name = workspace .ref_name() .context("workspace metadata update requires workspace ref")? .to_owned(); - ws_meta.set_project_meta(workspace.graph.project_meta.clone()); + ws_meta.set_project_meta(workspace.project_meta().clone()); self.inner .pending_metadata_updates @@ -412,13 +424,13 @@ where .try_find_reference(ref_name)? .map(|reference| reference.target().into()); - let graph = self - .inner - .rebase - .as_ref() - .expect("rebase is always Some(_)") - .overlayed_graph()?; - let workspace = graph.into_workspace()?; + let workspace = but_workspace::workspace::overlayed_workspace( + &self.inner.workspace, + self.inner + .rebase + .as_ref() + .expect("rebase is always Some(_)"), + )?; let (anchor, anchor_segment_oldest_commit_id) = match anchor { Some(but_workspace::branch::create_reference::Anchor::AtSegment { ref_name, @@ -445,11 +457,7 @@ where .commits .last() .map(|commit| commit.id) - .or_else(|| { - workspace - .tip_commit_by_segment_id(segment.id) - .map(|commit| commit.id) - }) + .or_else(|| workspace.branch_resting_commit_id(ref_name.as_ref())) .ok_or_else(|| { anyhow::anyhow!( "Cannot position reference below unborn segment '{}'", @@ -568,7 +576,7 @@ where | None => { let target = editor.select_commit(target_id)?; let reference = editor.add_step(reference)?; - editor.add_edge(reference, target, 0)?; + editor.insert_edge(reference, target, 0)?; } } Ok(((), editor.rebase()?)) @@ -743,10 +751,10 @@ where fn rebase(&mut self, f: F) -> anyhow::Result where F: FnOnce( - Editor<'rebase, 'rebase, M>, + Editor<'rebase, M>, &CommitMappings, &mut but_db::Transaction<'rebase>, - ) -> anyhow::Result<(T, SuccessfulRebase<'rebase, 'rebase, M>)>, + ) -> anyhow::Result<(T, SuccessfulRebase<'rebase, M>)>, { let editor = self .inner @@ -984,7 +992,8 @@ pub trait TransactionOutcome: sealed::Sealed { fn maybe_commit( self, repo: &gix::Repository, - rebase: SuccessfulRebase<'_, '_, M>, + workspace: &mut but_graph::Workspace, + rebase: SuccessfulRebase<'_, M>, db_tx: but_db::Transaction<'_>, pending_metadata_removals: Vec, pending_metadata_updates: Vec, @@ -1004,7 +1013,8 @@ impl TransactionOutcome for () { fn maybe_commit( self, repo: &gix::Repository, - rebase: SuccessfulRebase<'_, '_, M>, + workspace: &mut but_graph::Workspace, + rebase: SuccessfulRebase<'_, M>, db_tx: but_db::Transaction<'_>, pending_metadata_removals: Vec, pending_metadata_updates: Vec, @@ -1014,6 +1024,7 @@ impl TransactionOutcome for () { let ws = workspace_state_from_rebase( rebase, repo, + workspace, pending_metadata_removals, pending_metadata_updates, pending_created_independent_refs, @@ -1041,7 +1052,8 @@ impl TransactionOutcome for Rollback { fn maybe_commit( self, _repo: &gix::Repository, - _rebase: SuccessfulRebase<'_, '_, M>, + _workspace: &mut but_graph::Workspace, + _rebase: SuccessfulRebase<'_, M>, _db_tx: but_db::Transaction<'_>, _pending_metadata_removals: Vec, _pending_metadata_updates: Vec, @@ -1067,7 +1079,8 @@ impl TransactionOutcome for Commit { fn maybe_commit( self, repo: &gix::Repository, - rebase: SuccessfulRebase<'_, '_, M>, + workspace: &mut but_graph::Workspace, + rebase: SuccessfulRebase<'_, M>, db_tx: but_db::Transaction<'_>, pending_metadata_removals: Vec, pending_metadata_updates: Vec, @@ -1077,6 +1090,7 @@ impl TransactionOutcome for Commit { let workspace = workspace_state_from_rebase( rebase, repo, + workspace, pending_metadata_removals, pending_metadata_updates, pending_created_independent_refs, @@ -1107,7 +1121,8 @@ impl TransactionOutcome for DynamicOutcome { fn maybe_commit( self, repo: &gix::Repository, - rebase: SuccessfulRebase<'_, '_, M>, + workspace: &mut but_graph::Workspace, + rebase: SuccessfulRebase<'_, M>, db_tx: but_db::Transaction<'_>, pending_metadata_removals: Vec, pending_metadata_updates: Vec, @@ -1119,6 +1134,7 @@ impl TransactionOutcome for DynamicOutcome { let workspace = workspace_state_from_rebase( rebase, repo, + workspace, pending_metadata_removals, pending_metadata_updates, pending_created_independent_refs, @@ -1135,8 +1151,9 @@ impl TransactionOutcome for DynamicOutcome { } fn workspace_state_from_rebase( - rebase: SuccessfulRebase<'_, '_, M>, + rebase: SuccessfulRebase<'_, M>, repo: &gix::Repository, + workspace: &mut but_graph::Workspace, pending_metadata_removals: Vec, pending_metadata_updates: Vec, pending_created_independent_refs: Vec, @@ -1144,14 +1161,14 @@ fn workspace_state_from_rebase( ) -> anyhow::Result { if dry_run.into() { return WorkspaceState::from_successful_rebase_without_pr_associations( - rebase, repo, dry_run, + workspace, rebase, repo, dry_run, ); } let materialized = rebase.materialize()?; + workspace.refresh_from_commit_graph(materialized.arena().clone(), repo, materialized.meta)?; for branch in pending_created_independent_refs { - if materialized - .workspace + if workspace .find_segment_and_stack_by_refname(branch.name.as_ref()) .is_some() { @@ -1159,7 +1176,7 @@ fn workspace_state_from_rebase( } let outcome = but_workspace::branch::apply( branch.name.as_ref(), - materialized.workspace.clone(), + workspace.clone(), repo, materialized.meta, but_workspace::branch::apply::Options { @@ -1167,7 +1184,7 @@ fn workspace_state_from_rebase( ..Default::default() }, )?; - *materialized.workspace = outcome.workspace; + *workspace = outcome.workspace; } for update in pending_metadata_updates { match update { @@ -1188,7 +1205,7 @@ fn workspace_state_from_rebase( } WorkspaceState::from_workspace_without_pr_associations( - materialized.workspace, + workspace, materialized.meta, repo, materialized.history.commit_mappings(), diff --git a/crates/but-transaction/src/tests.rs b/crates/but-transaction/src/tests.rs index a37e5fa7f78..f0dc41cec6d 100644 --- a/crates/but-transaction/src/tests.rs +++ b/crates/but-transaction/src/tests.rs @@ -2,7 +2,8 @@ use but_api::WorkspaceState; use but_core::{DiffSpec, DryRun}; use but_ctx::Context; use but_oplog::legacy::{OperationKind, SnapshotDetails}; -use but_rebase::graph_rebase::mutate::{InsertSide, RelativeTo}; +use but_rebase::graph_rebase::mutate::InsertSide; +use but_rebase::graph_rebase::selector::RelativeTo; use but_testsupport::Sandbox; use but_workspace::{ branch::create_reference::{Anchor, Position}, diff --git a/crates/but-workspace/src/branch/apply.rs b/crates/but-workspace/src/branch/apply.rs index 9460531ac2b..0be5c88a9b1 100644 --- a/crates/but-workspace/src/branch/apply.rs +++ b/crates/but-workspace/src/branch/apply.rs @@ -176,7 +176,10 @@ use but_core::{ WorkspaceCommitRelation::{Merged, Outside}, }, }; -use but_graph::{SegmentIndex, init::Overlay, petgraph::Direction, workspace::WorkspaceKind}; +use but_graph::{ + walk::Overlay, + workspace::{StackTip, WorkspaceKind}, +}; use gix::{ prelude::ObjectIdExt, reference::Category, @@ -190,7 +193,7 @@ use tracing::instrument; use crate::{ WorkspaceCommit, branch::{anon_stacks, ensure_no_missing_stacks}, - commit::merge::Tip, + commit::merge::Seed, ref_info::WorkspaceExt, }; @@ -234,7 +237,7 @@ pub fn apply( let branch_orig = branch; let (mut branch_ref, mut incoming_branch_is_remote_tracking_without_local_tracking) = (try_find_validated_ref(repo, branch, "apply")?, false); - if ws.is_branch_the_target_or_its_local_tracking_branch(branch) { + if ws.is_target_or_its_local_tracking(branch) { bail!("Cannot add the target '{branch}' branch to its own workspace"); } let mut branch = branch.to_owned(); @@ -273,8 +276,7 @@ pub fn apply( } else if !branch_has_applied_metadata && ws.refname_is_segment(branch.as_ref()) { // This means our workspace encloses the desired branch, but it's not checked out yet. let commit_to_checkout = ws - .tip_commit() - .map(|commit| commit.id) + .tip_commit_id() .context("Workspace must point to a commit to check out")?; let ws_ref_name = ws.ref_name().map(|rn| rn.to_owned()); but_core::worktree::safe_checkout_from_head( @@ -294,14 +296,11 @@ pub fn apply( add_branch_as_stack_forcefully(&mut ws_md, branch.as_ref(), order, new_stack_id); persist_metadata_and_gitconfig(meta, &applied_branches, &ws_md, None)?; } - let ws = ws - .graph - .redo_traversal_with_overlay( - repo, - meta, - Overlay::default().with_entrypoint(commit_to_checkout, ws_ref_name.clone()), - )? - .into_workspace()?; + let ws = ws.redo( + repo, + meta, + Overlay::default().with_entrypoint(commit_to_checkout, ws_ref_name.clone()), + )?; set_head_to_reference( repo, commit_to_checkout, @@ -367,11 +366,8 @@ pub fn apply( // soon-to-be-created workspace. // This is a 'trick' to allow callers to prevent 'main' to be added to the workspace automatically // even though the new workspace is supposed to have it as target. - let is_branch_target = next_ws_md - .is_branch_the_target_or_its_local_tracking_branch( - current_head_ref.as_ref(), - repo, - )?; + let is_branch_target = + next_ws_md.is_target_or_its_local_tracking(current_head_ref.as_ref(), repo)?; if is_branch_target { current_unmanaged_head_branch_name.take(); } @@ -399,7 +395,7 @@ pub fn apply( { None => { // Pretend to create a workspace reference later at the current AdHoc workspace id - let tip = ws.tip_commit().map(|c| c.id).context( + let tip = ws.tip_commit_id().context( "BUG: how can an empty ad-hoc workspace exist? Should have at least one stack-segment with commit", )?; (tip, false) @@ -487,14 +483,11 @@ pub fn apply( })) .with_branch_metadata_override(branch_mds) .with_workspace_metadata_override(ws_md_override); - let ws = ws - .graph - .redo_traversal_with_overlay(repo, meta, overlay.clone())? - .into_workspace()?; + let ws = ws.redo(repo, meta, overlay.clone())?; let all_applied_branches_are_already_visible = branches_to_apply.iter().all(|rn| { ws.find_segment_and_stack_by_refname(rn.as_ref()) - .is_some_and(|(_stack, segment)| !segment.is_projected_from_outside(&ws.graph)) + .is_some_and(|(_stack, segment)| !segment.name_projected_from_outside) }); let needs_ws_ref_creation = !ws_ref_exists; let local_tracking_config_and_ref_info = local_tracking_config_and_ref_info @@ -527,11 +520,11 @@ pub fn apply( head_id.object()?.peel_to_tree()?.id, )?; let ws_commit_with_new_message = ws_commit_with_new_message.id.detach(); - let (graph, new_head_id) = if (ws_commit_with_new_message != head_id + let (ws, new_head_id) = if (ws_commit_with_new_message != head_id && ws.kind.has_managed_commit()) || needs_workspace_commit_without_remerge(&ws, integration_mode) { - let graph = ws.graph.redo_traversal_with_overlay( + let ws = ws.redo( repo, meta, overlay.with_entrypoint( @@ -539,9 +532,9 @@ pub fn apply( Some(workspace_ref_name_to_update.clone()), ), )?; - (graph, ws_commit_with_new_message) + (ws, ws_commit_with_new_message) } else { - (ws.graph, ws_ref_id) + (ws, ws_ref_id) }; set_head_to_reference( @@ -552,7 +545,7 @@ pub fn apply( (!head_on_workspace_ref).then_some(workspace_ref_name_to_update.as_ref()), )?; return Ok(Outcome { - workspace: graph.into_workspace()?, + workspace: ws, status: OutcomeStatus::Applied, workspace_ref_created: needs_ws_ref_creation, workspace_merge: None, @@ -584,7 +577,7 @@ pub fn apply( anon_stacks(&ws.stacks), &existing_stacks_superseded_by_branch, ), - &ws.graph, + &ws, &in_memory_repo, Some(branch.as_ref()), )?; @@ -612,11 +605,7 @@ pub fn apply( let overlay = overlay .with_entrypoint(new_head_id, Some(workspace_ref_name_to_update.clone())) .with_workspace_metadata_override(ws_md_override); - let graph = ws - .graph - .redo_traversal_with_overlay(&in_memory_repo, meta, overlay.clone())?; - - let mut ws = graph.into_workspace()?; + let mut ws = ws.redo(&in_memory_repo, meta, overlay.clone())?; let collect_unapplied_branches = |ws: &but_graph::Workspace| { branches_to_apply .iter() @@ -699,7 +688,7 @@ pub fn apply( anon_stacks(&ws.stacks), &existing_stacks_superseded_by_branch, ), - &ws.graph, + &ws, &in_memory_repo, Some(branch.as_ref()), )?; @@ -721,16 +710,13 @@ pub fn apply( conflicting_stacks = correlate_conflicting_stacks(&ws_md, &merge_result.conflicting_stacks); remove_conflicting_stacks_from_workspace(&mut ws_md, &conflicting_stacks); let ws_md_override = Some((workspace_ref_name_to_update.clone(), (*ws_md).clone())); - ws = ws - .graph - .redo_traversal_with_overlay( - &in_memory_repo, - meta, - overlay - .with_entrypoint(new_head_id, Some(workspace_ref_name_to_update.clone())) - .with_workspace_metadata_override(ws_md_override), - )? - .into_workspace()?; + ws = ws.redo( + &in_memory_repo, + meta, + overlay + .with_entrypoint(new_head_id, Some(workspace_ref_name_to_update.clone())) + .with_workspace_metadata_override(ws_md_override), + )?; let unapplied_branches = collect_unapplied_branches(&ws); if !unapplied_branches.is_empty() { @@ -853,33 +839,23 @@ fn branch_has_applied_workspace_metadata( fn filter_superseded_metadata_stacks<'a>( stack_iter: impl Iterator, - existing_stacks_superseded_by_branch: &[( - SegmentIndex, - Option, - Option, - )], + existing_stacks_superseded_by_branch: &[StackTip], ) -> impl Iterator { stack_iter.into_iter().filter(|ws_stack| { !existing_stacks_superseded_by_branch .iter() - .any(|(_sidx, ref_name, _cid)| ws_stack.ref_name() == ref_name.as_ref()) + .any(|tip| ws_stack.ref_name() == tip.ref_name.as_ref()) }) } fn filter_superseded_anon_stacks( - tips_iter: impl Iterator, - existing_stacks_superseded_by_branch: &[( - SegmentIndex, - Option, - Option, - )], -) -> impl Iterator { + tips_iter: impl Iterator, + existing_stacks_superseded_by_branch: &[StackTip], +) -> impl Iterator { tips_iter.filter(|(_parent_idx, anon_tip)| { !existing_stacks_superseded_by_branch .iter() - .any(|(sidx, _ref_name, cid)| { - anon_tip.segment_idx == *sidx || cid.is_some_and(|cid| cid == anon_tip.commit_id) - }) + .any(|tip| tip.commit_id.is_some_and(|cid| cid == anon_tip.commit_id)) }) } @@ -898,54 +874,20 @@ fn find_superseded_stacks( branch: &FullNameRef, workspace: &but_graph::Workspace, ws_meta: &mut ref_metadata::Workspace, -) -> Vec<( - SegmentIndex, - Option, - Option, -)> { - let graph = &workspace.graph; - let superseded = if let Some(branch_segment) = graph.segment_by_ref_name(branch) { - // At this stage we know first segment isn't in the workspace, so exclude it. - let _tip_commit_ids_and_sidx: Vec<_> = workspace - .stacks - .iter() - .filter_map(|stack| { - stack - .segments - .first() - .and_then(|s| s.commits.first().map(|c| (c.id, s.id))) - }) - .collect(); - let mut superseded = Vec::new(); - graph.visit_all_segments_excluding_start_until( - branch_segment.id, - Direction::Outgoing, - |segment| { - let prune = _tip_commit_ids_and_sidx.iter().any(|(cid, sidx)| { - segment.id == *sidx || segment.commits.first().is_some_and(|c| c.id == *cid) - }); - if prune { - superseded.push(( - segment.id, - segment.ref_name().map(|rn| rn.to_owned()), - segment.commits.first().map(|c| c.id), - )); - } - prune - }, - ); - superseded - } else { - tracing::warn!( - ?branch, - "Didn't find branch in graph to do the 'reaches into workspace' check" - ); - Vec::new() - }; +) -> Vec { + let superseded = workspace + .stack_tip_segments_below_ref(branch) + .unwrap_or_else(|| { + tracing::warn!( + ?branch, + "Didn't find branch in graph to do the 'reaches into workspace' check" + ); + Vec::new() + }); let metadata_stacks_to_remove = superseded .iter() - .filter_map(|t| t.1.as_ref().map(|rn| rn.as_ref())) + .filter_map(|tip| tip.ref_name.as_ref().map(|rn| rn.as_ref())) .filter_map(|superseded_tip_name| { ws_meta .find_owner_indexes_by_name(superseded_tip_name, StackKind::Applied) diff --git a/crates/but-workspace/src/branch/create_reference.rs b/crates/but-workspace/src/branch/create_reference.rs index b404c7ae668..db4f9b86274 100644 --- a/crates/but-workspace/src/branch/create_reference.rs +++ b/crates/but-workspace/src/branch/create_reference.rs @@ -332,16 +332,12 @@ pub(super) mod function { Some(Anchor::AtSegment { ref_name, position }) => { let mut validate_id = true; let ref_target_id = if workspace.has_metadata() { - let (stack_idx, seg_idx) = - workspace.try_find_segment_owner_indexes_by_refname(ref_name.as_ref())?; - let segment = &workspace.stacks[stack_idx].segments[seg_idx]; - - let id = workspace - .tip_commit_by_segment_id(segment.id) - .map(|commit| position.resolve_commit(commit.into(), ws_base)) - .context( - "BUG: we should always see through to the base or eligible commits", - )??; + let resting_id = workspace.try_branch_resting_commit_id(ref_name.as_ref())?; + let commit = workspace + .commit_graph() + .node(resting_id) + .context("BUG: the resting commit is part of the graph")?; + let id = position.resolve_commit(commit.into(), ws_base)?; if Some(id) == ws_base { validate_id = false } @@ -384,15 +380,8 @@ pub(super) mod function { ); } if workspace.has_metadata() { - let (stack_idx, seg_idx) = - workspace.try_find_segment_owner_indexes_by_refname(anchor_ref.as_ref())?; - let segment = &workspace.stacks[stack_idx].segments[seg_idx]; - let ref_target_id = workspace - .tip_commit_by_segment_id(segment.id) - .map(|commit| commit.id) - .context( - "BUG: we should always see through to the base or eligible commits", - )?; + let ref_target_id = + workspace.try_branch_resting_commit_id(anchor_ref.as_ref())?; AnchorResolution::positioned( ref_target_id, Some(ref_target_id) != ws_base, @@ -433,12 +422,12 @@ pub(super) mod function { workspace.try_find_owner_indexes_by_commit_id(ref_target_id)?; } - let graph_with_new_ref = { + let updated_workspace = { // Always update the metadata, this may help disambiguating. let mut branch_md = meta.branch(ref_name)?; update_branch_metadata(ref_name, repo, &mut branch_md)?; - let mut overlay = but_graph::init::Overlay::default() + let mut overlay = but_graph::walk::Overlay::default() .with_references_if_new(Some(gix::refs::Reference { name: ref_name.into(), target: gix::refs::Target::Object(ref_target_id), @@ -460,12 +449,9 @@ pub(super) mod function { overlay = overlay.with_entrypoint(ref_target_id, Some(new_tip)); } - workspace - .graph - .redo_traversal_with_overlay(repo, meta, overlay)? + workspace.redo(repo, meta, overlay)? }; - let updated_workspace = graph_with_new_ref.into_workspace()?; let has_new_ref_as_standalone_segment = updated_workspace .find_segment_and_stack_by_refname(ref_name) .is_some(); @@ -611,7 +597,7 @@ pub(super) mod function { /// anchor). /// - If `new_ref` is already present it is moved (removed then re-inserted), so re-creating or /// reordering the same branch is idempotent. - /// - [`Position::Above`] takes the anchor's slot, pushing the anchor down; [`Position::Below`] + /// - [`Position::Above`] takes the anchor's parent number, pushing the anchor down; [`Position::Below`] /// goes right after the anchor. This only affects *ordering* โ€” unlike [`Anchor::AtSegment`], /// it never changes which branch owns the commit. fn insert_into_branch_stack_order( diff --git a/crates/but-workspace/src/branch/integrate_branch_upstream/mod.rs b/crates/but-workspace/src/branch/integrate_branch_upstream/mod.rs index 3e1c4aec3da..b114033c83c 100644 --- a/crates/but-workspace/src/branch/integrate_branch_upstream/mod.rs +++ b/crates/but-workspace/src/branch/integrate_branch_upstream/mod.rs @@ -7,7 +7,7 @@ use anyhow::{Result, bail}; use but_core::{RefMetadata, commit::Headers}; use but_rebase::graph_rebase::{ Editor, LookupStep, SuccessfulRebase, ToSelector, - mutate::{SegmentDelimiter, SelectorSet}, + selector::{SelectorSet, StepRange}, }; use crate::graph_manipulation::{EdgeSelection, connect_segment_to_edges, selected_edges_from_set}; @@ -110,19 +110,24 @@ pub struct InitialBranchIntegration { /// /// `steps` - The vector of steps in the application order (parent to child) that describe the actions to perform /// for the integration of the changes. -pub fn integrate_branch_with_steps<'ws, 'meta, M: RefMetadata>( +pub fn integrate_branch_with_steps<'meta, M: RefMetadata>( ref_name: &gix::refs::FullNameRef, integration: InteractiveIntegration, - workspace: &'ws mut but_graph::Workspace, + workspace: &but_graph::Workspace, meta: &'meta mut M, repo: &gix::Repository, -) -> Result> { +) -> Result> { if integration.steps.is_empty() { bail!("Integration steps cannot be empty") } // The editor maps every segment in the graph, including the remote // reference of the branch we're integrating. - let mut editor = Editor::create(workspace, meta, repo)?; + let mut editor = Editor::create( + workspace.commit_graph(), + workspace.project_meta(), + meta, + repo, + )?; // Step 1: We prepare the steps before building. // At this point, we construct the commits for the squash steps in memory. let prepared_steps = prepare_integration_steps_for_editor(&editor, &integration.steps)?; @@ -136,7 +141,7 @@ pub fn integrate_branch_with_steps<'ws, 'meta, M: RefMetadata>( }; // Segment, from local-ref to the parent-most non-integrated local commit. // This represents the bounds of the commit chain we're about to manipulate and rebuild. - let segment_delimiter = SegmentDelimiter { + let range = StepRange { child: delimiter_child, parent: delimiter_parent, }; @@ -148,7 +153,7 @@ pub fn integrate_branch_with_steps<'ws, 'meta, M: RefMetadata>( let children_to_reconnect = selected_edges_from_set( &editor, - segment_delimiter.child, + range.child, &children_to_disconnect, EdgeSelection::Children, )?; @@ -162,7 +167,7 @@ pub fn integrate_branch_with_steps<'ws, 'meta, M: RefMetadata>( .collect::>(); let parents_to_reconnect = selected_edges_from_set( &editor, - segment_delimiter.parent, + range.parent, &parents_to_disconnect, EdgeSelection::Parents, )? @@ -179,12 +184,7 @@ pub fn integrate_branch_with_steps<'ws, 'meta, M: RefMetadata>( .collect::>>()?; // Step 3: Disconnect the segment, isolating it so that we can freely manipulate it. - editor.disconnect_segment_from( - segment_delimiter, - children_to_disconnect, - parents_to_disconnect, - true, - )?; + editor.disconnect_range_from(range, children_to_disconnect, parents_to_disconnect, true)?; // Step 4: Based on the prepared steps, we rebuild the chain. let new_segment_delimiter = @@ -233,7 +233,7 @@ fn integration_step_commit_ids(steps: &[InteractiveIntegrationStep]) -> HashSet< pub fn get_initial_integration_steps_for_branch( ref_name: &gix::refs::FullNameRef, strategy: BranchIntegrationStrategy, - workspace: &mut but_graph::Workspace, + workspace: &but_graph::Workspace, meta: &mut M, repo: &gix::Repository, ) -> Result { @@ -246,7 +246,12 @@ pub fn get_initial_integration_steps_for_branch( .map(|target| target.ref_name.clone()) .filter(|target_ref_name| target_ref_name.as_ref() != upstream_ref_name.as_ref()); - let editor = Editor::create(workspace, meta, repo)?; + let editor = Editor::create( + workspace.commit_graph(), + workspace.project_meta(), + meta, + repo, + )?; // Step 2: We traverse the editor graph and determine the divergence between the local and remote branch. let BranchMergeBaseCommits { diff --git a/crates/but-workspace/src/branch/integrate_branch_upstream/plan.rs b/crates/but-workspace/src/branch/integrate_branch_upstream/plan.rs index 839dee4ee6b..13159504a33 100644 --- a/crates/but-workspace/src/branch/integrate_branch_upstream/plan.rs +++ b/crates/but-workspace/src/branch/integrate_branch_upstream/plan.rs @@ -13,7 +13,7 @@ use but_rebase::{ graph_rebase::{ Editor, LookupStep, Selector, Step, merge_commit_changes::MergeCommitChangesOutcome, - mutate::{SegmentDelimiter, SelectorSet}, + selector::{SelectorSet, StepRange}, }, }; @@ -210,7 +210,7 @@ pub(super) enum PreparedIntegrationStep { /// /// Returns the normalized execution plan used by later graph-building helpers. pub(super) fn prepare_integration_steps_for_editor( - editor: &Editor<'_, '_, M>, + editor: &Editor<'_, M>, steps: &[InteractiveIntegrationStep], ) -> Result> { steps @@ -234,7 +234,7 @@ pub(super) fn prepare_integration_steps_for_editor( /// Precompute the squash payload from the current editor/repository state, /// before later integration graph mutations can rewire step-graph ancestry. fn prepare_squash_step_for_editor( - editor: &Editor<'_, '_, M>, + editor: &Editor<'_, M>, commit_ids: &[gix::ObjectId], message: Option<&str>, ) -> Result { @@ -333,13 +333,13 @@ fn apply_merge_commit_changes_outcome( /// `steps` is the prepared execution plan to insert under `ref_name`, ending /// at the deepest rebuilt parent step. /// -/// Returns the delimiter spanning from the reference node to the deepest +/// Returns the range spanning from the reference node to the deepest /// inserted parent. pub(crate) fn integration_steps_into_segment_nodes( - editor: &mut Editor<'_, '_, M>, + editor: &mut Editor<'_, M>, ref_name: &gix::refs::FullNameRef, steps: &[PreparedIntegrationStep], -) -> Result> { +) -> Result> { // Step 1: We interpret the integration steps and transform them into graph steps disconnected from their parents. // We disconnect them in order to be able to allow for reordering. let segment_steps = integration_steps_to_segment_steps_for_editor(editor, ref_name, steps)?; @@ -362,7 +362,7 @@ pub(crate) fn integration_steps_into_segment_nodes( parent_most = connect_parent_step(editor, parent_most, step)?; } - Ok(SegmentDelimiter { + Ok(StepRange { child: child_most, parent: parent_most, }) @@ -381,7 +381,7 @@ pub(crate) fn integration_steps_into_segment_nodes( /// Returns the graph steps to insert, starting with a reference step and then /// the parent chain steps in insertion order. fn integration_steps_to_segment_steps_for_editor( - editor: &mut Editor<'_, '_, M>, + editor: &mut Editor<'_, M>, ref_name: &gix::refs::FullNameRef, steps: &[PreparedIntegrationStep], ) -> Result> { @@ -413,7 +413,10 @@ fn integration_steps_to_segment_steps_for_editor( pick.preserved_parents = Some(preserved_parents); let commit_to_merge = editor.add_step(commit_to_merge)?; let merge_commit = editor.add_step(Step::new_untracked_pick(merge_commit))?; - editor.add_edge(merge_commit, commit_to_merge, 1)?; + // The merged side is the merge's only parent for now; the rebuilt chain's + // parent prepends at parent number 0 later (`connect_parent_step`), shifting it to + // the merge-side lane. + editor.insert_edge(merge_commit, commit_to_merge, 0)?; out.push(editor.lookup_step(merge_commit)?); } } @@ -434,13 +437,13 @@ fn integration_steps_to_segment_steps_for_editor( /// selected parent edges, or a brand-new pick step when the commit is not yet /// selectable in the editor. fn existing_or_new_pick_step( - editor: &mut Editor<'_, '_, M>, + editor: &mut Editor<'_, M>, commit_id: gix::ObjectId, ) -> Result { if let Some(existing) = editor.try_select_commit(commit_id) { let parents_to_disconnect = determine_parent_selector(editor, existing)?; - editor.disconnect_segment_from( - SegmentDelimiter { + editor.disconnect_range_from( + StepRange { child: existing, parent: existing, }, diff --git a/crates/but-workspace/src/branch/mod.rs b/crates/but-workspace/src/branch/mod.rs index 2be6c41e118..fdc46c35380 100644 --- a/crates/but-workspace/src/branch/mod.rs +++ b/crates/but-workspace/src/branch/mod.rs @@ -91,7 +91,7 @@ //! Alternatively, auto-resolves could be deactivated if we are in Git mode, and instead they are applied //! to the worktree directly, and we wait (with sequencer support) until the conflict has been resolved. //! -//! ## Workspace Tip +//! ## Workspace Seed //! //! This is the elaborate name of the commit that currently represents what's visible in the working tree, //! i.e. the commit that `HEAD` points to. @@ -482,24 +482,21 @@ impl OnWorkspaceMergeConflict { /// and must be supplied explicitly when rebuilding a workspace merge commit. /// /// Each returned tuple is `(parent_index, tip)`: `parent_index` is the stack's position in the -/// projected workspace so the merge builder can insert the anonymous tip at the same parent slot, +/// projected workspace so the merge builder can insert the anonymous tip at the same parent number, /// and `tip` is the commit/segment pair to merge, with no ref name attached. pub(crate) fn anon_stacks( stacks: &[but_graph::workspace::Stack], -) -> impl Iterator { +) -> impl Iterator { stacks.iter().enumerate().filter_map(|(idx, s)| { if s.ref_name().is_none() { - s.tip_skip_empty().and_then(|cid| { - s.segments.first().map(|s| { - ( - idx, - crate::commit::merge::Tip { - name: None, - commit_id: cid, - segment_idx: s.id, - }, - ) - }) + s.tip_skip_empty().map(|cid| { + ( + idx, + crate::commit::merge::Seed { + name: None, + commit_id: cid, + }, + ) }) } else { None diff --git a/crates/but-workspace/src/branch/move_branch.rs b/crates/but-workspace/src/branch/move_branch.rs index 7d1e0b32199..4077b2055fe 100644 --- a/crates/but-workspace/src/branch/move_branch.rs +++ b/crates/but-workspace/src/branch/move_branch.rs @@ -5,9 +5,9 @@ use but_rebase::graph_rebase::SuccessfulRebase; /// /// Returned by [function::move_branch()]. #[derive(Debug)] -pub struct Outcome<'ws, 'meta, M: RefMetadata> { +pub struct Outcome<'meta, M: RefMetadata> { /// A successful rebase result for continuing operations. - pub rebase: SuccessfulRebase<'ws, 'meta, M>, + pub rebase: SuccessfulRebase<'meta, M>, /// The updated workspace metadata that accompanies the move operation. /// It should replace the actual workspace metadata to configure moved 'virtual' branches segments, if `Some()`. pub ws_meta: Option, @@ -28,7 +28,8 @@ pub(super) mod function { use but_core::RefMetadata; use but_core::ref_metadata::StackId; - use but_rebase::graph_rebase::mutate::SomeSelectors; + use but_rebase::graph_rebase::mutate::InsertSide; + use but_rebase::graph_rebase::selector::{SelectorSet, SomeSelectors, StepRange}; use crate::graph_manipulation::DisconnectParameters; use crate::graph_manipulation::get_disconnect_parameters; @@ -55,13 +56,15 @@ pub(super) mod function { /// Mainly used for testing purposes. /// /// Returns the in memory update [outcome](Outcome) that can then used for materialisation. - pub fn tear_off_branch<'ws, 'meta, M: RefMetadata>( - editor: Editor<'ws, 'meta, M>, + pub fn tear_off_branch<'meta, M: RefMetadata>( + editor: Editor<'meta, M>, + current_workspace: &but_graph::Workspace, subject_branch_name: &FullNameRef, stack_id_override: Option, - ) -> anyhow::Result> { + ) -> anyhow::Result> { let successful_rebase = editor.rebase()?; - let workspace = successful_rebase.overlayed_graph()?.into_workspace()?; + let workspace = + crate::workspace::overlayed_workspace(current_workspace, &successful_rebase)?; let mut editor = successful_rebase.into_editor(); let Some(source) = workspace.find_segment_and_stack_by_refname(subject_branch_name) else { bail!( @@ -84,7 +87,7 @@ pub(super) mod function { let mut ws_meta = workspace.metadata.clone(); if let Some(ws_meta) = ws_meta.as_mut() { - ws_meta.set_project_meta(workspace.graph.project_meta.clone()); + ws_meta.set_project_meta(workspace.project_meta().clone()); } let (source_stack, subject_segment) = source; @@ -99,18 +102,14 @@ pub(super) mod function { }); } - let Some(workspace_head) = workspace.tip_commit().map(|commit| commit.id) else { + let Some(workspace_head) = workspace.tip_commit_id() else { bail!("Couldn't find workspace head.") }; let head_selector = editor .select_commit(workspace_head) .context("Failed to find the workspace head in the graph.")?; - let Some(lower_bound_ref) = workspace - .lower_bound_segment_id - .map(|segment_id| &workspace.graph[segment_id]) - .and_then(|segment| segment.ref_name()) - else { + let Some(lower_bound_ref) = workspace.lower_bound_ref_name() else { bail!("Tearing off a branch requires a workspace common base"); }; @@ -119,7 +118,7 @@ pub(super) mod function { .context("Failed to find target reference in graph.")?; let DisconnectParameters { - delimiter: subject_delimiter, + range: subject_delimiter, children_to_disconnect, parents_to_disconnect, } = get_disconnect_parameters( @@ -129,7 +128,7 @@ pub(super) mod function { Some(workspace_head), )?; - editor.disconnect_segment_from( + editor.disconnect_range_from( subject_delimiter.clone(), children_to_disconnect, parents_to_disconnect, @@ -138,7 +137,7 @@ pub(super) mod function { let selectors = SomeSelectors::new(vec![head_selector])?; - editor.insert_segment_into( + editor.insert_range_into( target_selector, subject_delimiter, but_rebase::graph_rebase::mutate::InsertSide::Above, @@ -180,17 +179,19 @@ pub(super) mod function { /// branch on top of. /// /// Returns an [outcome](Outcome) for potential materialisation. - pub fn move_branch<'ws, 'meta, M: RefMetadata>( - editor: Editor<'ws, 'meta, M>, + pub fn move_branch<'meta, M: RefMetadata>( + editor: Editor<'meta, M>, + current_workspace: &but_graph::Workspace, subject_branch_name: &FullNameRef, target_branch_name: &FullNameRef, - ) -> anyhow::Result> { + ) -> anyhow::Result> { if subject_branch_name == target_branch_name { bail!("Cannot move branch {subject_branch_name} onto itself"); } let successful_rebase = editor.rebase()?; - let workspace = successful_rebase.overlayed_graph()?.into_workspace()?; + let workspace = + crate::workspace::overlayed_workspace(current_workspace, &successful_rebase)?; let (source, destination) = retrieve_branches_and_containers(&workspace, subject_branch_name, target_branch_name)?; @@ -229,14 +230,14 @@ pub(super) mod function { /// The reordered chain is returned in [`Outcome::branch_stack_order`] for the caller to persist /// (via [`RefMetadata::set_branch_stack_order`]) rather than being written here, so callers can /// skip persistence for dry-run previews. - fn move_branch_in_single_branch_mode<'ws, 'meta, M: RefMetadata>( - mut successful_rebase: SuccessfulRebase<'ws, 'meta, M>, + fn move_branch_in_single_branch_mode<'meta, M: RefMetadata>( + mut successful_rebase: SuccessfulRebase<'meta, M>, workspace: but_graph::Workspace, source: WorkspaceSegmentContext, destination: WorkspaceSegmentContext, subject_branch_name: &FullNameRef, target_branch_name: &FullNameRef, - ) -> anyhow::Result> { + ) -> anyhow::Result> { let (source_stack, subject_segment) = &source; let (destination_stack, _) = &destination; let entrypoint = workspace.ref_name().map(ToOwned::to_owned); @@ -249,9 +250,15 @@ pub(super) mod function { // Reordering same-target empty refs only changes which empty segment is displayed first. // If their targets differ, however, the subject crosses commit-owning segments and its ref // must move with it or those commits would be projected as belonging to the empty branch. - let move_requires_graph_update = !subject_segment.commits.is_empty() - || successful_rebase.reference_target(subject_branch_name)? - != successful_rebase.reference_target(target_branch_name)?; + // When the subject and target rest on the same commit, no commit relocates: the subject + // (the commit owner) stays put and the empty segments that sit above it on that shared + // commit drop onto its parent commit. Moving the subject's own commit range in that case is + // a degenerate, self-referential move that collides the shared-commit refs. + let subject_shares_target_commit = successful_rebase + .reference_target(subject_branch_name)? + == successful_rebase.reference_target(target_branch_name)?; + let move_requires_graph_update = + !subject_segment.commits.is_empty() || !subject_shares_target_commit; let existing_order = { let (_repo, meta) = successful_rebase.repo_and_meta_mut(); if !meta.can_persist_branch_stack_order() { @@ -300,32 +307,76 @@ pub(super) mod function { } if move_requires_graph_update { - let (_, target_segment) = destination; - let target_segment_ref_name = target_segment - .ref_name() - .context("Target segment doesn't have a ref")?; + let segment_index = |name: &FullNameRef| { + source_stack + .segments + .iter() + .position(|segment| segment.ref_name() == Some(name)) + }; + let subject_index = segment_index(subject_branch_name) + .context("BUG: subject segment missing from its source stack")?; + let target_index = segment_index(target_branch_name) + .context("BUG: target segment missing from the source stack")?; + + // The same-commit case: the subject stays, and the empty segments between it and the + // target (which all share its tip) drop onto the subject's parent commit โ€” the base. + let between = source_stack + .segments + .get(target_index..subject_index) + .unwrap_or(&[]); + let empties_drop_below_subject = subject_shares_target_commit + && target_index < subject_index + && between.iter().all(|segment| segment.commits.is_empty()); + let mut editor = successful_rebase.into_editor(); - let target_selector = editor - .select_reference(target_segment_ref_name) - .context("Failed to find target reference in graph.")?; - - let DisconnectParameters { - delimiter: subject_delimiter, - children_to_disconnect, - parents_to_disconnect, - } = get_disconnect_parameters(&editor, source_stack, subject_segment, None)?; - - editor.disconnect_segment_from( - subject_delimiter.clone(), - children_to_disconnect, - parents_to_disconnect, - false, - )?; - editor.insert_segment( - target_selector, - subject_delimiter, - but_rebase::graph_rebase::mutate::InsertSide::Above, - )?; + + if empties_drop_below_subject { + // Re-anchor each empty into the base commit's group (above the base owner just + // below the subject), bottom to top, so it re-points onto the base and the group + // keeps its order below the subject. A single-entry reference range unhooks the + // empty from its current position without touching any commit. + let mut anchor_ref = source_stack + .segments + .get(subject_index + 1) + .and_then(|segment| segment.ref_name()) + .context("No base segment below the subject to re-anchor empties onto")? + .to_owned(); + for segment in between.iter().rev() { + let empty_ref = segment + .ref_name() + .context("Empty segment doesn't have a ref")?; + let empty = editor.select_reference(empty_ref)?; + let range = StepRange { + child: empty, + parent: empty, + }; + editor.disconnect_range_from( + range.clone(), + SelectorSet::All, + SelectorSet::All, + false, + )?; + let anchor = editor.select_reference(anchor_ref.as_ref())?; + editor.insert_range(anchor, range, InsertSide::Above)?; + anchor_ref = empty_ref.to_owned(); + } + } else { + let target_selector = editor + .select_reference(target_branch_name) + .context("Failed to find target reference in graph.")?; + let DisconnectParameters { + range: subject_delimiter, + children_to_disconnect, + parents_to_disconnect, + } = get_disconnect_parameters(&editor, source_stack, subject_segment, None)?; + editor.disconnect_range_from( + subject_delimiter.clone(), + children_to_disconnect, + parents_to_disconnect, + false, + )?; + editor.insert_range(target_selector, subject_delimiter, InsertSide::Above)?; + } return Ok(Outcome { rebase: editor.rebase()?, @@ -344,21 +395,21 @@ pub(super) mod function { } /// Move a branch within a managed workspace (one backed by a workspace commit). - fn move_branch_in_managed_workspace<'ws, 'meta, M: RefMetadata>( - successful_rebase: SuccessfulRebase<'ws, 'meta, M>, + fn move_branch_in_managed_workspace<'meta, M: RefMetadata>( + successful_rebase: SuccessfulRebase<'meta, M>, workspace: but_graph::Workspace, source: WorkspaceSegmentContext, destination: WorkspaceSegmentContext, subject_branch_name: &FullNameRef, target_branch_name: &FullNameRef, - ) -> anyhow::Result> { - let Some(workspace_head) = workspace.tip_commit().map(|commit| commit.id) else { + ) -> anyhow::Result> { + let Some(workspace_head) = workspace.tip_commit_id() else { bail!("Couldn't find workspace head.") }; let mut ws_meta = workspace.metadata.clone(); if let Some(ws_meta) = ws_meta.as_mut() { - ws_meta.set_project_meta(workspace.graph.project_meta.clone()); + ws_meta.set_project_meta(workspace.project_meta().clone()); } let (source_stack, subject_segment) = source; @@ -387,7 +438,7 @@ pub(super) mod function { .context("Failed to find target reference in graph.")?; let DisconnectParameters { - delimiter: subject_delimiter, + range: subject_delimiter, children_to_disconnect, parents_to_disconnect, } = get_disconnect_parameters( @@ -398,13 +449,13 @@ pub(super) mod function { )?; let skip_reconnect_step = source_stack.segments.len() == 1; - editor.disconnect_segment_from( + editor.disconnect_range_from( subject_delimiter.clone(), children_to_disconnect, parents_to_disconnect, skip_reconnect_step, )?; - editor.insert_segment( + editor.insert_range( target_selector, subject_delimiter, but_rebase::graph_rebase::mutate::InsertSide::Above, @@ -445,7 +496,7 @@ pub(super) mod function { .segments .iter() .zip(&right.segments) - .all(|(left, right)| left.id == right.id) + .all(|(left, right)| left.ref_name() == right.ref_name()) } fn stack_branch_order(stack: &but_graph::workspace::Stack) -> Vec { diff --git a/crates/but-workspace/src/branch/remove_reference.rs b/crates/but-workspace/src/branch/remove_reference.rs index 5024d4dcef4..240e8c8afe8 100644 --- a/crates/but-workspace/src/branch/remove_reference.rs +++ b/crates/but-workspace/src/branch/remove_reference.rs @@ -84,10 +84,7 @@ pub fn remove_reference( } let stack_id = stack.id; - let mut graph = workspace - .graph - .redo_traversal_with_overlay(repo, meta, Default::default())?; - let ws = graph.into_workspace()?; + let ws = workspace.redo(repo, meta, Default::default())?; if avoid_anonymous_stacks { let Some(stack) = ws.stacks.iter().find(|s| s.id == stack_id) else { // The whole stack is gone, so nothing that could be anonymous. @@ -104,7 +101,7 @@ pub fn remove_reference( .iter() .find_map(|s| { let rn = s.ref_name()?; - ws.tip_commit_by_segment_id(s.id).map(|c| (rn, c.id)) + ws.branch_resting_commit_id(rn).map(|id| (rn, id)) }) .with_context(|| { "BUG: should not try to delete branch if anon \ @@ -117,10 +114,7 @@ pub fn remove_reference( PreviousValue::MustExistAndMatch(gix::refs::Target::Object(target_id)), "move segment reference up to avoid anonymous stack", )?; - graph = ws - .graph - .redo_traversal_with_overlay(repo, meta, Default::default())?; - Ok(Some(graph.into_workspace()?)) + Ok(Some(ws.redo(repo, meta, Default::default())?)) } else { Ok(Some(ws)) } diff --git a/crates/but-workspace/src/branch/unapply.rs b/crates/but-workspace/src/branch/unapply.rs index b46db285049..637039c5180 100644 --- a/crates/but-workspace/src/branch/unapply.rs +++ b/crates/but-workspace/src/branch/unapply.rs @@ -130,7 +130,7 @@ pub(crate) mod function { ObjectStorageExt as _, RefMetadata, RepositoryExt as _, ref_metadata::{ProjectMeta, ProjectedWorkspaceStack, StackId}, }; - use but_graph::init::Overlay; + use but_graph::walk::Overlay; use gix::{ prelude::ObjectIdExt, refs::{ @@ -275,10 +275,7 @@ pub(crate) mod function { // TODO: this will actually be observable even if it doens't work, unless it's run in a transaction, which right now it's not! // Should be able to redo the traversal with an overlay that hides branch metadata, but I'd say it's not important enough. meta.remove(branch)?; - let graph = ws - .graph - .redo_traversal_with_overlay(repo, meta, Overlay::default())?; - let workspace = graph.into_workspace()?; + let workspace = ws.redo(repo, meta, Overlay::default())?; if workspace.refname_is_segment(branch) { bail!( "Cannot unapply branch '{branch}' from an ad-hoc workspace because non-tip branches can only disappear if their now removed metadata disambiguated them", @@ -291,19 +288,16 @@ pub(crate) mod function { // Everything past this point is stricly in non-dry-run mode and we may totally end up in intermediate states // if something fails. // Redo the traversal with the changed workspace metadata so code below can rely on the reconciled version. - let ws = ws - .graph - .redo_traversal_with_overlay( - repo, - meta, - Overlay::default() - .with_dropped_references([branch.to_owned()]) - .with_workspace_metadata_override(Some(( - workspace_ref_name.to_owned(), - ws_md.clone(), - ))), - )? - .into_workspace()?; + let ws = ws.redo( + repo, + meta, + Overlay::default() + .with_dropped_references([branch.to_owned()]) + .with_workspace_metadata_override(Some(( + workspace_ref_name.to_owned(), + ws_md.clone(), + ))), + )?; // Normal unapply first: // - re-merge or collapse the workspace commit // - point workspace to it @@ -324,10 +318,7 @@ pub(crate) mod function { let overlay = Overlay::default() .with_dropped_references([branch.to_owned()]) .with_workspace_metadata_override(Some((workspace_ref_name.to_owned(), ws_md.clone()))); - let mut ws = ws - .graph - .redo_traversal_with_overlay(repo, meta, overlay)? - .into_workspace()?; + let mut ws = ws.redo(repo, meta, overlay)?; let checked_out = if !workspace_tip_was_entrypoint && (ws.is_entrypoint() || branch_stack_was_entrypoint) { @@ -338,10 +329,7 @@ pub(crate) mod function { let overlay = Overlay::default() .with_dropped_references([branch.to_owned()]) .with_entrypoint(entrypoint_id, Some(workspace_ref_name.to_owned())); - ws = ws - .graph - .redo_traversal_with_overlay(repo, meta, overlay)? - .into_workspace()?; + ws = ws.redo(repo, meta, overlay)?; Some(workspace_ref_name.to_owned()) } else { None @@ -374,9 +362,8 @@ pub(crate) mod function { repo, ref_to_switch_to.ref_name.as_ref(), workspace_ref_name.as_ref(), - ws.tip_commit() - .context("BUG: unborn should be impossible here")? - .id, + ws.tip_commit_id() + .context("BUG: unborn should be impossible here")?, )?; // Keep the workspace metadata or we lose the target branch. // Currently that's a problem, so deal with it later. @@ -387,10 +374,7 @@ pub(crate) mod function { Some(ref_to_switch_to.ref_name.clone()), ) .with_dropped_references([branch.to_owned()]); - let ws = ws - .graph - .redo_traversal_with_overlay(repo, meta, overlay)? - .into_workspace()?; + let ws = ws.redo(repo, meta, overlay)?; Ok(Outcome { workspace: Cow::Owned(ws), @@ -470,8 +454,8 @@ pub(crate) mod function { disposition: WorkspaceDisposition, excluded_anonymous_tip_id: Option, ) -> anyhow::Result { - let future_workspace_tips = future_workspace_tips(ws_md, ws, excluded_anonymous_tip_id)?; - let remaining_tip_count = future_workspace_tips.len(); + let future_workspace_seeds = future_workspace_seeds(ws_md, ws, excluded_anonymous_tip_id)?; + let remaining_tip_count = future_workspace_seeds.len(); let keep_workspace_commit = match disposition { WorkspaceDisposition::KeepWorkspaceCommit | WorkspaceDisposition::PreventUnnecessaryWorkspaceReferencesKeepWorkspaceCommit => { @@ -485,7 +469,7 @@ pub(crate) mod function { if !keep_workspace_commit { let new_head_id = - commit_to_point_workspace_ref_to_after_unapply(ws, &future_workspace_tips)?; + commit_to_point_workspace_ref_to_after_unapply(ws, &future_workspace_seeds)?; checkout_and_update_workspace_ref(repo, new_head_id, workspace_ref_name)?; return Ok(WorkspaceRefUpdateAfterUnapply { entrypoint_id: new_head_id, @@ -494,7 +478,7 @@ pub(crate) mod function { } let mut in_memory_repo = repo.clone().for_tree_diffing()?.with_object_memory(); - let merge = merge_workspace_after_unapply(ws, &future_workspace_tips, &in_memory_repo)?; + let merge = merge_workspace_after_unapply(ws, &future_workspace_seeds, &in_memory_repo)?; // materialize the merged objects. let new_head_id = merge.workspace_commit_id; @@ -527,18 +511,18 @@ pub(crate) mod function { workspace_merge: Option, } - fn future_workspace_tips( + fn future_workspace_seeds( ws_md: &but_core::ref_metadata::Workspace, ws: &but_graph::Workspace, excluded_anonymous_tip_id: Option, - ) -> anyhow::Result> { + ) -> anyhow::Result> { let crate::commit::merge::ResolvedTips { tips, missing_stacks, } = WorkspaceCommit::tips_from_metadata( ws_md.stacks.iter(), anon_stacks_to_preserve(ws, excluded_anonymous_tip_id), - &ws.graph, + ws, ); ensure!( missing_stacks.is_empty(), @@ -581,9 +565,8 @@ pub(crate) mod function { )?; let workspace_ref_expected = ws - .tip_commit() - .context("BUG: unborn should be impossible here")? - .id; + .tip_commit_id() + .context("BUG: unborn should be impossible here")?; switch_head_and_delete_workspace_ref( repo, ref_to_checkout.ref_name.as_ref(), @@ -602,10 +585,7 @@ pub(crate) mod function { ref_to_checkout.commit_id, Some(ref_to_checkout.ref_name.clone()), ); - let ws = ws - .graph - .redo_traversal_with_overlay(repo, meta, overlay)? - .into_workspace()?; + let ws = ws.redo(repo, meta, overlay)?; Ok(Outcome { workspace: Cow::Owned(ws), checked_out: Some(ref_to_checkout.ref_name), @@ -621,15 +601,15 @@ pub(crate) mod function { /// base/checkout commit. fn merge_workspace_after_unapply( ws: &but_graph::Workspace, - future_workspace_tips: &[crate::commit::merge::Tip], + future_workspace_seeds: &[crate::commit::merge::Seed], repo: &gix::Repository, ) -> anyhow::Result { - let mut tips = future_workspace_tips.to_vec(); + let mut tips = future_workspace_seeds.to_vec(); let report_workspace_merge = !tips.is_empty(); if tips.is_empty() { - tips.push(base_tip_after_unapply(ws, future_workspace_tips)?); + tips.push(base_tip_after_unapply(ws, future_workspace_seeds)?); } - let outcome = WorkspaceCommit::from_new_merge_with_tips(tips, &ws.graph, repo, None)?; + let outcome = WorkspaceCommit::from_new_merge_with_tips(tips, ws, repo, None)?; ensure_workspace_merge_has_no_conflicts(&outcome)?; let workspace_commit_id = outcome.workspace_commit_id; Ok(WorkspaceMergeAfterUnapply { @@ -667,20 +647,12 @@ pub(crate) mod function { /// Convert the commit that should remain checked out after unapply into a synthetic merge tip. fn base_tip_after_unapply( ws: &but_graph::Workspace, - future_workspace_tips: &[crate::commit::merge::Tip], - ) -> anyhow::Result { - let commit_id = commit_to_point_workspace_ref_to_after_unapply(ws, future_workspace_tips)?; - let segment_idx = ws - .graph - .segment_by_commit_id(commit_id) - .with_context(|| { - format!("BUG: could not find workspace segment for base commit {commit_id}") - })? - .id; - Ok(crate::commit::merge::Tip { + future_workspace_seeds: &[crate::commit::merge::Seed], + ) -> anyhow::Result { + let commit_id = commit_to_point_workspace_ref_to_after_unapply(ws, future_workspace_seeds)?; + Ok(crate::commit::merge::Seed { name: None, commit_id, - segment_idx, }) } @@ -693,7 +665,7 @@ pub(crate) mod function { fn anon_stacks_to_preserve<'a>( ws: &'a but_graph::Workspace, excluded_tip_id: Option, - ) -> impl Iterator + 'a { + ) -> impl Iterator + 'a { anon_stacks(&ws.stacks) .filter(move |(idx, _)| ws.stacks.get(*idx).is_none_or(|stack| stack.id.is_none())) .filter(move |(_, tip)| excluded_tip_id.is_none_or(|id| tip.commit_id != id)) @@ -706,30 +678,33 @@ pub(crate) mod function { if let Some(target) = local_tracking_branch_of_target(ws)? { return Ok(target); } - named_stack_with_lowest_generation(ws)?.with_context( + most_recent_named_stack(ws)?.with_context( || "Cannot unapply workspace reference because no target or named stack could be found", ) } - /// The idea here is to put the user at the topologically most recent stack. - /// This is also arbitrary, but *feels* like what one would want. - fn named_stack_with_lowest_generation( - ws: &but_graph::Workspace, - ) -> anyhow::Result> { + /// The idea here is to put the user at the topologically most recent stack โ€” the one whose + /// first segment's anchor commit (its tip, or the pointed-at commit for an empty splice) + /// sits deepest in the carried commit graph; stack order breaks ties. This is also + /// arbitrary, but *feels* like what one would want. + fn most_recent_named_stack(ws: &but_graph::Workspace) -> anyhow::Result> { + let cg = ws.commit_graph(); let mut selected = None; for stack in &ws.stacks { - let Some((sidx, ref_info)) = stack - .segments - .first() - .and_then(|s| s.ref_info.as_ref().map(|ri| (s.id, ri))) - else { + let Some((ref_info, anchor)) = stack.segments.first().and_then(|s| { + s.ref_info + .as_ref() + .map(|ri| (ri, s.commits.first().map(|c| c.id).or(ri.commit_id))) + }) else { continue; }; - let generation = ws.graph[sidx].generation; - let ref_to_checkout = RefToCheckout::from_segment_ref_info(ws, sidx, ref_info)?; + let generation = anchor + .and_then(|anchor| cg.generation_of(anchor)) + .unwrap_or(0); + let ref_to_checkout = RefToCheckout::from_segment_ref_info(ws, ref_info)?; if selected .as_ref() - .is_none_or(|(best_generation, _)| generation < *best_generation) + .is_none_or(|(best_generation, _)| generation > *best_generation) { selected = Some((generation, ref_to_checkout)); } @@ -781,9 +756,9 @@ pub(crate) mod function { /// resolved target or the workspace lower bound. fn commit_to_point_workspace_ref_to_after_unapply( ws: &but_graph::Workspace, - future_workspace_tips: &[crate::commit::merge::Tip], + future_workspace_seeds: &[crate::commit::merge::Seed], ) -> anyhow::Result { - if let Some(tip) = future_workspace_tips.first() { + if let Some(tip) = future_workspace_seeds.first() { return Ok(tip.commit_id); } ws.resolved_target_commit_id() @@ -876,30 +851,31 @@ pub(crate) mod function { segment .ref_info .as_ref() - .map(|ref_info| RefToCheckout::from_segment_ref_info(ws, segment.id, ref_info)) + .map(|ref_info| RefToCheckout::from_segment_ref_info(ws, ref_info)) }) .transpose() } - /// Return the local branch to check out for the workspace target. + /// The local branch tracking the workspace target (e.g. `main` for `origin/main`), resolved + /// as data: the graph's carried tracking map names it, the carried commit graph positions it. /// - /// `ws` is the current graph projection with adjusted metadata. The workspace target already - /// carries the local tracking branch inferred while building the graph, including the peeled - /// commit id to check out. + /// `ws` is the current graph projection with adjusted metadata. fn local_tracking_branch_of_target( ws: &but_graph::Workspace, ) -> anyhow::Result> { let Some(target_ref) = ws.target_ref.as_ref() else { return Ok(None); }; - let Some(local_target_ref_sidx) = ws.graph[target_ref.segment_index].sibling_segment_id - else { - return Ok(None); - }; - let Some(ref_info) = ws.graph[local_target_ref_sidx].ref_info.as_ref() else { - return Ok(None); - }; - RefToCheckout::from_segment_ref_info(ws, local_target_ref_sidx, ref_info).map(Some) + Ok(ws + .local_tracking_branch(target_ref.ref_name.as_ref()) + .and_then(|local| { + ws.commit_graph() + .commit_by_ref(local.as_ref()) + .map(|commit_id| RefToCheckout { + ref_name: local.clone(), + commit_id, + }) + })) } /// Ref name and peeled commit id selected from the workspace projection for checkout. @@ -912,14 +888,12 @@ pub(crate) mod function { impl RefToCheckout { fn from_segment_ref_info( ws: &but_graph::Workspace, - segment_id: but_graph::SegmentIndex, ref_info: &but_graph::RefInfo, ) -> anyhow::Result { Ok(RefToCheckout { ref_name: ref_info.ref_name.clone(), commit_id: ws - .tip_commit_by_segment_id(segment_id) - .map(|commit| commit.id) + .branch_resting_commit_id(ref_info.ref_name.as_ref()) .or(ref_info.commit_id) .with_context(|| { format!( diff --git a/crates/but-workspace/src/changeset.rs b/crates/but-workspace/src/changeset.rs index e1473fed7f6..80b225b82f2 100644 --- a/crates/but-workspace/src/changeset.rs +++ b/crates/but-workspace/src/changeset.rs @@ -60,38 +60,15 @@ impl RefInfo { /// If `expensive` is `true`, we will run checks that involve changeset-id computation and squash-merge trials. pub(crate) fn compute_similarity( &mut self, - graph: &but_graph::Graph, + workspace: &but_graph::Workspace, repo: &gix::Repository, expensive: bool, ) -> anyhow::Result<()> { - let topmost_target_sidx = self - .target_ref - .as_ref() - .map(|t| t.segment_index) - .or(self.target_commit.as_ref().map(|t| t.segment_index)); - let mut upstream_commits = Vec::new(); - let Some(target_tip) = topmost_target_sidx else { + let Some(upstream_commits) = workspace.upstream_commit_ids_outside_shared_history() else { // Without any notion of 'target' we can't do anything here. - self.compute_pushstatus(graph); + self.compute_pushstatus(workspace); return Ok(()); }; - let lower_bound_generation = self.lower_bound.map(|sidx| graph[sidx].generation); - graph.visit_all_segments_including_start_until( - target_tip, - but_graph::petgraph::Direction::Outgoing, - |s| { - let prune = true; - if Some(s.id) == self.lower_bound - || lower_bound_generation.is_some_and(|generation| s.generation > generation) - { - return prune; - } - for c in &s.commits { - upstream_commits.push(c.id); - } - !prune - }, - ); let cost_info = ( upstream_commits.len(), @@ -211,18 +188,18 @@ impl RefInfo { break; } } - self.compute_pushstatus(graph); + self.compute_pushstatus(workspace); Ok(()) } /// Recalculate everything that depends on these values and the exact set of remote commits. - fn compute_pushstatus(&mut self, graph: &but_graph::Graph) { + fn compute_pushstatus(&mut self, workspace: &but_graph::Workspace) { for segment in self .stacks .iter_mut() .flat_map(|stack| stack.segments.iter_mut()) { - segment.push_status = derive_push_status_from_graph(graph, segment); + segment.push_status = derive_push_status(workspace, segment); } } } @@ -251,11 +228,11 @@ impl RefInfo { /// would rewrite a branch state that was already merged /// - otherwise, either the remote is ahead of us on its branch line or the /// two tips diverged; both cases require force-push -fn derive_push_status_from_graph( - graph: &but_graph::Graph, +fn derive_push_status( + workspace: &but_graph::Workspace, segment: &crate::ref_info::Segment, ) -> PushStatus { - let Some(remote_segment_id) = segment.remote_tracking_branch_segment_id else { + let Some(remote_ref_name) = segment.remote_tracking_ref_name.as_ref() else { // Generally, don't do anything if no remote relationship is set up (anymore). // There may be better ways to deal with this. return PushStatus::CompletelyUnpushed; @@ -269,17 +246,15 @@ fn derive_push_status_from_graph( return PushStatus::Integrated; } - let local_segment_id = segment.id; - let Some(local_tip_id) = graph - .tip_skip_empty(local_segment_id) - .map(|commit| commit.id) - else { + let cg = workspace.commit_graph(); + let Some(local_tip_id) = segment.commits.first().map(|commit| commit.id).or_else(|| { + // An empty branch sits on the commit its ref points to. + let ref_name = segment.ref_info.as_ref()?.ref_name.as_ref(); + cg.commit_by_ref(ref_name) + }) else { return PushStatus::NothingToPush; }; - let Some(remote_tip_id) = graph - .tip_skip_empty(remote_segment_id) - .map(|commit| commit.id) - else { + let Some(remote_tip_id) = cg.commit_by_ref(remote_ref_name.as_ref()) else { // A missing remote tip acts like an unpushed branch: there is a // remote configured, but nothing reachable on that side that could // block a normal push. @@ -298,7 +273,7 @@ fn derive_push_status_from_graph( if local_tip_id == remote_tip_id { // Same tip, regardless of how the graph was segmented. PushStatus::NothingToPush - } else if first_parent_contains_commit(graph, local_segment_id, remote_tip_id) { + } else if first_parent_line_contains(cg, local_tip_id, remote_tip_id) { // Local is a straightforward first-parent extension of remote. // However, if this segment already contains an integrated commit // below a local tip, we preserve the previous behavior and treat it @@ -319,38 +294,20 @@ fn derive_push_status_from_graph( } } -/// Return `true` if `sought_commit_id` occurs on the first-parent branch line -/// of `start_segment_id`. -/// -/// This is stricter than an all-parents reachability test on purpose: -/// -/// - a merge can make a commit reachable without making it part of the branch's -/// own line -/// - pushability is about whether one branch tip can advance another branch tip -/// without rewriting that line -/// - therefore "reachable somewhere in history" is not the right predicate for -/// `ahead/behind` here -fn first_parent_contains_commit( - graph: &but_graph::Graph, - start_segment_id: but_graph::SegmentIndex, - sought_commit_id: gix::ObjectId, +/// Return `true` if `needle` occurs on the first-parent line starting at (and including) `start`. +fn first_parent_line_contains( + cg: &but_graph::CommitGraph, + start: gix::ObjectId, + needle: gix::ObjectId, ) -> bool { - let mut found = false; - if graph[start_segment_id] - .commits - .iter() - .any(|commit| commit.id == sought_commit_id) - { - return true; + let mut cursor = Some(start); + while let Some(commit) = cursor { + if commit == needle { + return true; + } + cursor = cg.first_parent(commit); } - graph.visit_segments_downward_along_first_parent_exclude_start(start_segment_id, |segment| { - found = segment - .commits - .iter() - .any(|commit| commit.id == sought_commit_id); - found - }); - found + false } fn is_similarity_candidate(commit: &crate::ref_info::LocalCommit) -> bool { diff --git a/crates/but-workspace/src/commit/commit_amend.rs b/crates/but-workspace/src/commit/commit_amend.rs index ea9325e0845..b4e0baa5b56 100644 --- a/crates/but-workspace/src/commit/commit_amend.rs +++ b/crates/but-workspace/src/commit/commit_amend.rs @@ -10,11 +10,11 @@ use super::compute_merge_base_override; /// The result of amending a commit in the graph rebase editor. #[derive(Debug)] -pub struct CommitAmendOutcome<'ws, 'meta, M: RefMetadata> { +pub struct CommitAmendOutcome<'meta, M: RefMetadata> { /// A successful rebase result for continuing operations. This will be /// always provided regardless of whether a commit was actually /// created. - pub rebase: SuccessfulRebase<'ws, 'meta, M>, + pub rebase: SuccessfulRebase<'meta, M>, /// Selector pointing to the amended commit, if the amend was /// successful. /// @@ -39,12 +39,12 @@ pub struct CommitAmendOutcome<'ws, 'meta, M: RefMetadata> { /// this particular function call. The provided `context_lines` MUST align /// with the `context_lines` value used to generate the `DiffSpec`s passed /// in the `changes` parameter. -pub fn commit_amend<'ws, 'meta, M: RefMetadata>( - mut editor: Editor<'ws, 'meta, M>, +pub fn commit_amend<'meta, M: RefMetadata>( + mut editor: Editor<'meta, M>, commit: impl ToCommitSelector, changes: Vec, context_lines: u32, -) -> Result> { +) -> Result> { let (target_selector, target) = editor.find_selectable_commit(commit)?; let target_id = target.id; diff --git a/crates/but-workspace/src/commit/commit_create.rs b/crates/but-workspace/src/commit/commit_create.rs index aaf689ac810..1de9f1786e3 100644 --- a/crates/but-workspace/src/commit/commit_create.rs +++ b/crates/but-workspace/src/commit/commit_create.rs @@ -12,11 +12,11 @@ use super::compute_merge_base_override; /// The result of creating and inserting a new commit in the graph rebase editor. #[derive(Debug)] -pub struct CommitCreateOutcome<'ws, 'meta, M: RefMetadata> { +pub struct CommitCreateOutcome<'meta, M: RefMetadata> { /// A successful rebase result for continuing operations. This will be /// always provided regardless of whether a commit was actually /// created. - pub rebase: SuccessfulRebase<'ws, 'meta, M>, + pub rebase: SuccessfulRebase<'meta, M>, /// Selector pointing to the newly created commit, if one was created. /// /// A commit may not be created if all the diff_specs are rejected. See @@ -45,14 +45,14 @@ pub struct CommitCreateOutcome<'ws, 'meta, M: RefMetadata> { /// this particular function call. The provided `context_lines` MUST align /// with the `context_lines` value used to generate the `DiffSpec`s passed /// in the `changes` parameter. -pub fn commit_create<'ws, 'meta, M: RefMetadata>( - mut editor: Editor<'ws, 'meta, M>, +pub fn commit_create<'meta, M: RefMetadata>( + mut editor: Editor<'meta, M>, changes: Vec, relative_to: impl ToSelector, side: InsertSide, message: &str, context_lines: u32, -) -> Result> { +) -> Result> { let relative_to_selector = relative_to.to_selector(&editor)?; let parent_commit_id = parent_commit_id_for_new_commit(&editor, editor.lookup_step(relative_to_selector)?, side)?; @@ -108,8 +108,8 @@ pub fn commit_create<'ws, 'meta, M: RefMetadata>( }) } -fn parent_commit_id_for_new_commit<'ws, 'meta, M: RefMetadata>( - editor: &Editor<'ws, 'meta, M>, +fn parent_commit_id_for_new_commit<'meta, M: RefMetadata>( + editor: &Editor<'meta, M>, target_step: Step, side: InsertSide, ) -> Result> { diff --git a/crates/but-workspace/src/commit/discard_commit.rs b/crates/but-workspace/src/commit/discard_commit.rs index 9894021cbd7..8e6fb83dbdd 100644 --- a/crates/but-workspace/src/commit/discard_commit.rs +++ b/crates/but-workspace/src/commit/discard_commit.rs @@ -4,7 +4,7 @@ use anyhow::bail; use but_core::RefMetadata; use but_rebase::graph_rebase::{ Editor, Step, SuccessfulRebase, - mutate::{SegmentDelimiter, SelectorSet}, + selector::{SelectorSet, StepRange}, }; /// Discard one or more commits in a single rebase operation. @@ -12,10 +12,10 @@ use but_rebase::graph_rebase::{ /// Each commit is removed from history and its parents are reconnected to its /// children. All removals share a single editor session so only one rebase /// is performed. Duplicate commit IDs are silently deduplicated. -pub fn discard_commits<'ws, 'meta, M: RefMetadata>( - mut editor: Editor<'ws, 'meta, M>, +pub fn discard_commits<'meta, M: RefMetadata>( + mut editor: Editor<'meta, M>, subject_commits: impl IntoIterator, -) -> anyhow::Result> { +) -> anyhow::Result> { let mut seen = gix::hashtable::HashSet::default(); let mut count = 0usize; for commit_id in subject_commits { @@ -25,12 +25,12 @@ pub fn discard_commits<'ws, 'meta, M: RefMetadata>( count += 1; let (selector, _commit) = editor.find_selectable_commit(commit_id)?; - let delimiter = SegmentDelimiter { + let range = StepRange { child: selector, parent: selector, }; - editor.disconnect_segment_from(delimiter, SelectorSet::All, SelectorSet::All, false)?; + editor.disconnect_range_from(range, SelectorSet::All, SelectorSet::All, false)?; editor.replace(selector, Step::None)?; } diff --git a/crates/but-workspace/src/commit/insert_blank_commit.rs b/crates/but-workspace/src/commit/insert_blank_commit.rs index 3ea8af686ee..c9c446dc46f 100644 --- a/crates/but-workspace/src/commit/insert_blank_commit.rs +++ b/crates/but-workspace/src/commit/insert_blank_commit.rs @@ -8,11 +8,11 @@ use but_rebase::{ }; /// Inserts a blank commit relative to either a reference or a commit -pub fn insert_blank_commit<'ws, 'meta, M: RefMetadata>( - mut editor: Editor<'ws, 'meta, M>, +pub fn insert_blank_commit<'meta, M: RefMetadata>( + mut editor: Editor<'meta, M>, side: InsertSide, relative_to: impl ToSelector, -) -> Result<(SuccessfulRebase<'ws, 'meta, M>, Selector)> { +) -> Result<(SuccessfulRebase<'meta, M>, Selector)> { let commit = editor.empty_commit()?; let new_id = editor.new_commit(commit, DateMode::CommitterUpdateAuthorUpdate)?; diff --git a/crates/but-workspace/src/commit/mod.rs b/crates/but-workspace/src/commit/mod.rs index 213a52b781e..c365b94e500 100644 --- a/crates/but-workspace/src/commit/mod.rs +++ b/crates/but-workspace/src/commit/mod.rs @@ -96,7 +96,6 @@ pub mod merge { RepositoryExt, ref_metadata::{MaybeDebug, WorkspaceCommitRelation}, }; - use but_graph::SegmentIndex; use gix::prelude::ObjectIdExt; use tracing::instrument; @@ -105,14 +104,12 @@ pub mod merge { /// A optionally named tip that can be merged. #[derive(Debug, Clone)] - pub struct Tip { + pub struct Seed { /// The name of the reference that points to `commit_id`, or `None` if there is no such reference. /// The name is for use in the generated workspace commit message. pub name: Option, /// The commit that should be merged into the workspace commit. pub commit_id: gix::ObjectId, - /// The index to the top-most segment of the stack in the graph for use in merge-base computation. - pub segment_idx: SegmentIndex, } /// Tips resolved from workspace metadata, with references that metadata mentioned but the graph @@ -120,7 +117,7 @@ pub mod merge { /// Returned by [WorkspaceCommit::tips_from_metadata()]. pub struct ResolvedTips { /// Tips in the order they should appear as workspace commit parents. - pub tips: Vec, + pub tips: Vec, /// Metadata stack tips that couldn't be found in the graph. /// This is usually a problem, as the Graph is expected to contain everything of interest. pub missing_stacks: Vec, @@ -171,22 +168,22 @@ pub mod merge { /// Resolve workspace metadata and anonymous stacks into merge tips. /// /// This preserves metadata ordering, reports missing metadata stacks, and inserts anonymous - /// stacks at their projected parent slots while avoiding duplicate commit/segment tips. + /// stacks at their projected parent numbers while avoiding duplicate commit/segment tips. /// /// `stacks` are the workspace metadata stacks whose top branches should become named /// merge tips unless they are marked outside of the workspace. /// - /// `anon_stacks` are unnamed projected tips paired with the parent slot they occupied in + /// `anon_stacks` are unnamed projected tips paired with the parent number they occupied in /// the workspace projection, used to preserve anonymous parents not represented in metadata. /// - /// `graph` resolves metadata branch names to commit and segment ids. + /// `workspace` resolves metadata branch names to commit and segment ids. pub fn tips_from_metadata<'a>( stacks: impl IntoIterator, - anon_stacks: impl IntoIterator, - graph: &but_graph::Graph, + anon_stacks: impl IntoIterator, + workspace: &but_graph::Workspace, ) -> ResolvedTips { let mut missing_stacks = Vec::new(); - let mut tips_with_metadata_slots: Vec<_> = stacks + let mut tips_with_metadata_numbers: Vec<_> = stacks .into_iter() .filter_map(|s| s.branches.first().map(|b| (b, s.workspacecommit_relation))) .map(|(top_segment, relation)| { @@ -202,20 +199,19 @@ pub mod merge { WorkspaceCommitRelation::Outside => return None, } let stack_tip_name = top_segment.ref_name.as_ref(); - match graph.segment_and_commit_by_ref_name(stack_tip_name) { + match workspace.commit_id_by_ref_name(stack_tip_name) { None => { missing_stacks.push(top_segment.ref_name.to_owned()); None } - Some((segment, commit)) => Some(Tip { + Some(commit_id) => Some(Seed { name: Some(stack_tip_name.to_owned()), - commit_id: commit.id, - segment_idx: segment.id, + commit_id, }), } }) .collect(); - let named_tips = tips_with_metadata_slots + let named_tips = tips_with_metadata_numbers .iter() .flatten() .cloned() @@ -223,17 +219,15 @@ pub mod merge { let mut anon_stacks = anon_stacks.into_iter().collect::>(); anon_stacks.sort_by_key(|(idx, _)| *idx); for (idx, anon_tip) in anon_stacks { - if named_tips.iter().any(|t| { - t.commit_id == anon_tip.commit_id || t.segment_idx == anon_tip.segment_idx - }) { + if named_tips.iter().any(|t| t.commit_id == anon_tip.commit_id) { // prevent duplication of tips, make calling this easier as well. continue; } - tips_with_metadata_slots - .insert(idx.min(tips_with_metadata_slots.len()), Some(anon_tip)); + tips_with_metadata_numbers + .insert(idx.min(tips_with_metadata_numbers.len()), Some(anon_tip)); } ResolvedTips { - tips: tips_with_metadata_slots.into_iter().flatten().collect(), + tips: tips_with_metadata_numbers.into_iter().flatten().collect(), missing_stacks, } } @@ -242,8 +236,8 @@ pub mod merge { /// even if the tip is unnamed. /// Note that [`missing_stacks`](Outcome::missing_stacks) is never set. pub fn from_new_merge_with_tips( - tips: impl IntoIterator, - graph: &but_graph::Graph, + tips: impl IntoIterator, + workspace: &but_graph::Workspace, repo: &gix::Repository, hero_stack: Option<&gix::refs::FullNameRef>, ) -> anyhow::Result { @@ -251,7 +245,7 @@ pub mod merge { enum Instruction { Merge, MergeTrial { - hero_sidx: SegmentIndex, + hero_commit_id: gix::ObjectId, hero_tree_id: gix::ObjectId, }, Skip, @@ -266,13 +260,13 @@ pub mod merge { } } } - let mut tips: Vec<(Instruction, Tip)> = + let mut tips: Vec<(Instruction, Seed)> = tips.into_iter().map(|t| (I::Merge, t)).collect(); let mut ran_merge_trials_loop_safety = false; #[expect(clippy::indexing_slicing)] 'retry_loop: loop { - let mut prev_base_sidx = None; + let mut prev_base_commit_id = None; let mut merge_tree_id = None; let mut previous_tip = None; let (merge_options, conflict_kind) = repo.merge_options_fail_fast()?; @@ -280,25 +274,24 @@ pub mod merge { 'tips_loop: for tip_idx in 0..tips.len() { let ( mode, - Tip { + Seed { name: ref_name, commit_id, - segment_idx: sidx, }, ) = &mut tips[tip_idx]; - let sidx = *sidx; + let this_commit_id = *commit_id; if mode.should_skip() { continue; } let this_tree_id = peel_to_tree(commit_id.attach(repo))?; - if let Some((prev_tree_id, prev_sidx)) = previous_tip { - let (base_tree_id, base_sidx) = { + if let Some((prev_tree_id, prev_commit_id)) = previous_tip { + let (base_tree_id, base_commit_id) = { // This is critical: we enforce using the lowest merge-base by using // the previous iterations merge-base. // This is the same as computing the merge-base between the new // (non-existing merge-commit) and the next tip. - let left = prev_base_sidx.unwrap_or(prev_sidx); - compute_merge_base(graph, repo, left, sidx)? + let left = prev_base_commit_id.unwrap_or(prev_commit_id); + compute_merge_base(workspace, repo, left, this_commit_id)? }; let mut merge = repo.merge_trees( @@ -362,7 +355,7 @@ pub mod merge { I::Skip => { if saw_first_certain_conflict { *mode = I::MergeTrial { - hero_sidx: sidx, + hero_commit_id: this_commit_id, hero_tree_id: this_tree_id, }; has_merge_trials = true; @@ -385,14 +378,19 @@ pub mod merge { } // We are past possible trials and proceed as usual, with future conflicting stacks just being dropped. } else if let I::MergeTrial { - hero_sidx, + hero_commit_id, hero_tree_id, } = *mode { // This stack merged cleanly, and now we have to merge the hero into that result to see if it works. // This tells us if this is stack merges cleanly or causes a real conflict in conjunction with hero. - let base_tree_id = - compute_merge_base(graph, repo, base_sidx, hero_sidx)?.0; + let base_tree_id = compute_merge_base( + workspace, + repo, + base_commit_id, + hero_commit_id, + )? + .0; let merge = repo.merge_trees( base_tree_id, merge.tree.write()?, @@ -412,10 +410,10 @@ pub mod merge { continue 'tips_loop; } } - prev_base_sidx = Some(base_sidx); + prev_base_commit_id = Some(base_commit_id); merge_tree_id = merge.tree.write()?.detach().into(); } - previous_tip = Some((this_tree_id, sidx)); + previous_tip = Some((this_tree_id, this_commit_id)); } let (stacks, conflicting_stacks) = tips.iter().fold( @@ -423,7 +421,7 @@ pub mod merge { |(mut stacks, mut conflicting_stacks), ( mode, - Tip { + Seed { name: ref_name, commit_id, .. @@ -474,7 +472,7 @@ pub mod merge { } /// Using the names of the `stacks` stored in [workspace metadata](but_core::ref_metadata::Workspace), - /// create a new workspace commit with their tips extracted from `graph`. Note that stacks that don't exist in `graph` aren't fatal. + /// create a new workspace commit with their tips extracted from `workspace`. Note that stacks that don't exist in the graph aren't fatal. /// Also, this will create a workspace commit as it's desired, but not as it is, and the caller should assure that all branches are present. /// /// Use `anon_stacks` with `(parent_index, tip)` to fill-in anonymous commits that aren't listed in metadata, @@ -495,52 +493,41 @@ pub mod merge { #[instrument( name = "re-merge workspace commit", level = "debug", - skip(stacks, anon_stacks, graph, repo), + skip(stacks, anon_stacks, workspace, repo), err(Debug) )] pub fn from_new_merge_with_metadata<'a>( stacks: impl IntoIterator, - anon_stacks: impl IntoIterator, - graph: &but_graph::Graph, + anon_stacks: impl IntoIterator, + workspace: &but_graph::Workspace, repo: &gix::Repository, hero_stack: Option<&gix::refs::FullNameRef>, ) -> anyhow::Result { let ResolvedTips { tips, missing_stacks, - } = Self::tips_from_metadata(stacks, anon_stacks, graph); - let mut out = Self::from_new_merge_with_tips(tips, graph, repo, hero_stack)?; + } = Self::tips_from_metadata(stacks, anon_stacks, workspace); + let mut out = Self::from_new_merge_with_tips(tips, workspace, repo, hero_stack)?; out.missing_stacks = missing_stacks; Ok(out) } } fn compute_merge_base( - graph: &but_graph::Graph, + workspace: &but_graph::Workspace, repo: &gix::Repository, - left: SegmentIndex, - right: SegmentIndex, - ) -> anyhow::Result<(gix::ObjectId, SegmentIndex)> { - let base_sidx = graph.find_merge_base(left, right).with_context(|| { - format!( - "Couldn't find merge-base between segments {l} and {r} - they are disjoint in the commit-graph", - l = left.index(), - r = right.index() - ) - })?; - let base_commit_id = graph - .tip_skip_empty(base_sidx) + left: gix::ObjectId, + right: gix::ObjectId, + ) -> anyhow::Result<(gix::ObjectId, gix::ObjectId)> { + let base_commit_id = workspace + .commit_graph() + .merge_base(left, right) .with_context(|| { format!( - "Base segment {base} between {l} and {r} didn't have single commit reachable", - base = base_sidx.index(), - l = left.index(), - r = right.index() + "Couldn't find merge-base between {left} and {right} - they are disjoint in the commit-graph" ) - })? - .id - .attach(repo); - Ok((peel_to_tree(base_commit_id)?, base_sidx)) + })?; + Ok((peel_to_tree(base_commit_id.attach(repo))?, base_commit_id)) } fn peel_to_tree(commit: gix::Id) -> anyhow::Result { diff --git a/crates/but-workspace/src/commit/move_changes.rs b/crates/but-workspace/src/commit/move_changes.rs index 2b1026b9a4f..76ff2920e67 100644 --- a/crates/but-workspace/src/commit/move_changes.rs +++ b/crates/but-workspace/src/commit/move_changes.rs @@ -11,9 +11,9 @@ use crate::tree_manipulation::{ChangesSource, create_tree_without_diff}; /// The result of a move_changes_between_commits operation. #[derive(Debug)] -pub struct MoveChangesOutcome<'ws, 'meta, M: RefMetadata> { +pub struct MoveChangesOutcome<'meta, M: RefMetadata> { /// The successful rebase result - pub rebase: SuccessfulRebase<'ws, 'meta, M>, + pub rebase: SuccessfulRebase<'meta, M>, /// Selector pointing to the source commit (with changes removed) pub source_selector: Selector, /// Selector pointing to the destination commit (with changes added) @@ -38,13 +38,13 @@ pub struct MoveChangesOutcome<'ws, 'meta, M: RefMetadata> { /// Returns the rebase outcome along with selectors pointing to both the /// modified source and destination commits. The caller should call /// `outcome.rebase.materialize()` to persist the changes. -pub fn move_changes_between_commits<'ws, 'meta, M: RefMetadata>( - mut editor: Editor<'ws, 'meta, M>, +pub fn move_changes_between_commits<'meta, M: RefMetadata>( + mut editor: Editor<'meta, M>, source_commit: impl ToCommitSelector, destination_commit: impl ToCommitSelector, changes_to_move: impl IntoIterator, context_lines: u32, -) -> Result> { +) -> Result> { let (source_selector, source_commit) = editor.find_selectable_commit(source_commit)?; let (destination_selector, destination_commit) = editor.find_selectable_commit(destination_commit)?; diff --git a/crates/but-workspace/src/commit/move_commit.rs b/crates/but-workspace/src/commit/move_commit.rs index 251b28fb178..6db4e6a1f5f 100644 --- a/crates/but-workspace/src/commit/move_commit.rs +++ b/crates/but-workspace/src/commit/move_commit.rs @@ -4,7 +4,8 @@ use anyhow::bail; use but_core::RefMetadata; use but_rebase::graph_rebase::{ Editor, LookupStep as _, SuccessfulRebase, ToCommitSelector, ToSelector, - mutate::{InsertSide, RelativeTo, SegmentDelimiter, SelectorSet}, + mutate::InsertSide, + selector::{RelativeTo, SelectorSet, StepRange}, }; use crate::graph_manipulation::determine_parent_selector; @@ -21,12 +22,12 @@ use crate::graph_manipulation::determine_parent_selector; /// /// The subject commit will be detached from the source segment, and inserted relative /// to a given anchor (branch or commit). -pub fn move_commit<'ws, 'meta, M: RefMetadata>( - editor: Editor<'ws, 'meta, M>, +pub fn move_commit<'meta, M: RefMetadata>( + editor: Editor<'meta, M>, subject_commit: impl ToCommitSelector, anchor: impl ToSelector, side: InsertSide, -) -> anyhow::Result> { +) -> anyhow::Result> { let editor = move_commit_no_rebase(editor, subject_commit, anchor, side)?; editor.rebase() } @@ -35,12 +36,12 @@ pub fn move_commit<'ws, 'meta, M: RefMetadata>( /// /// The commits are ordered by parentage before moving so callers do not need to /// provide them in graph order. -pub fn move_commits<'ws, 'meta, M: RefMetadata>( - editor: Editor<'ws, 'meta, M>, +pub fn move_commits<'meta, M: RefMetadata>( + editor: Editor<'meta, M>, subject_commit_ids: impl IntoIterator, relative_to: RelativeTo, side: InsertSide, -) -> anyhow::Result> { +) -> anyhow::Result> { let subject_commit_ids = subject_commit_ids.into_iter().collect::>(); if subject_commit_ids.is_empty() { bail!("No commits were provided to move") @@ -87,15 +88,15 @@ pub fn move_commits<'ws, 'meta, M: RefMetadata>( /// to a given anchor (branch or commit). /// /// This function mutates the editor graph but does not execute a rebase. -pub fn move_commit_no_rebase<'ws, 'meta, M: RefMetadata>( - mut editor: Editor<'ws, 'meta, M>, +pub fn move_commit_no_rebase<'meta, M: RefMetadata>( + mut editor: Editor<'meta, M>, subject_commit: impl ToCommitSelector, anchor: impl ToSelector, side: InsertSide, -) -> anyhow::Result> { +) -> anyhow::Result> { let (subject_commit_selector, _) = editor.find_selectable_commit(subject_commit)?; - let commit_delimiter = SegmentDelimiter { + let commit_range = StepRange { child: subject_commit_selector, parent: subject_commit_selector, }; @@ -104,14 +105,14 @@ pub fn move_commit_no_rebase<'ws, 'meta, M: RefMetadata>( let parent_to_disconnect = determine_parent_selector(&editor, subject_commit_selector)?; // Step 2: Disconnect - editor.disconnect_segment_from( - commit_delimiter.clone(), + editor.disconnect_range_from( + commit_range.clone(), SelectorSet::All, parent_to_disconnect, false, )?; // Step 3: Insert - editor.insert_segment(anchor, commit_delimiter, side)?; + editor.insert_range(anchor, commit_range, side)?; Ok(editor) } diff --git a/crates/but-workspace/src/commit/reword.rs b/crates/but-workspace/src/commit/reword.rs index 088f53c0b8d..210995b3e43 100644 --- a/crates/but-workspace/src/commit/reword.rs +++ b/crates/but-workspace/src/commit/reword.rs @@ -12,11 +12,11 @@ use but_rebase::{ /// the new name. /// /// Returns a selector to the rewritten commit -pub fn reword<'ws, 'meta, M: RefMetadata>( - mut editor: Editor<'ws, 'meta, M>, +pub fn reword<'meta, M: RefMetadata>( + mut editor: Editor<'meta, M>, commit: impl ToCommitSelector, new_message: &BStr, -) -> Result<(SuccessfulRebase<'ws, 'meta, M>, Selector)> { +) -> Result<(SuccessfulRebase<'meta, M>, Selector)> { let (target_selector, mut commit) = editor.find_selectable_commit(commit)?; commit.message = new_message.to_owned(); diff --git a/crates/but-workspace/src/commit/squash_commits.rs b/crates/but-workspace/src/commit/squash_commits.rs index 34b0174a0ba..98acdfa60b3 100644 --- a/crates/but-workspace/src/commit/squash_commits.rs +++ b/crates/but-workspace/src/commit/squash_commits.rs @@ -7,15 +7,15 @@ use but_rebase::{ graph_rebase::{ Editor, LookupStep as _, Selector, Step, SuccessfulRebase, ToCommitSelector, merge_commit_changes::MergeCommitChangesOutcome, - mutate::{SegmentDelimiter, SelectorSet}, + selector::{SelectorSet, StepRange}, }, }; /// The result of a squash_commits operation. #[derive(Debug)] -pub struct SquashCommitsOutcome<'ws, 'meta, M: RefMetadata> { +pub struct SquashCommitsOutcome<'meta, M: RefMetadata> { /// The successful rebase result. - pub rebase: SuccessfulRebase<'ws, 'meta, M>, + pub rebase: SuccessfulRebase<'meta, M>, /// Selector pointing to the squashed replacement commit. pub commit_selector: Selector, } @@ -50,12 +50,12 @@ fn push_message_with_spacing(combined: &mut Vec, message: &[u8]) { /// /// Returns the updated editor and the selector that now points to the squashed /// commit. -fn construct_new_squashed_commit<'ws, 'meta, M: RefMetadata>( - mut editor: Editor<'ws, 'meta, M>, +fn construct_new_squashed_commit<'meta, M: RefMetadata>( + mut editor: Editor<'meta, M>, squashed_tree: MergeCommitChangesOutcome, target_commit_id: Selector, combined_message: Vec, -) -> Result<(Editor<'ws, 'meta, M>, Selector)> { +) -> Result<(Editor<'meta, M>, Selector)> { let (target_selector, target_commit) = editor.find_selectable_commit(target_commit_id)?; let target_parent_ids = parent_commit_ids(&editor, target_selector)?; @@ -73,7 +73,7 @@ fn construct_new_squashed_commit<'ws, 'meta, M: RefMetadata>( } fn parent_commit_ids( - editor: &Editor<'_, '_, M>, + editor: &Editor<'_, M>, selector: Selector, ) -> Result> { let mut parents = editor.direct_parents(selector)?; @@ -134,12 +134,12 @@ but_schemars::register_sdk_type!(MessageCombinationStrategy); /// Subject messages are appended in the order they are provided, with at least /// one blank line between non-empty message blocks. /// -pub fn squash_commits<'ws, 'meta, M: RefMetadata, S: ToCommitSelector, T: ToCommitSelector>( - editor: Editor<'ws, 'meta, M>, +pub fn squash_commits<'meta, M: RefMetadata, S: ToCommitSelector, T: ToCommitSelector>( + editor: Editor<'meta, M>, subjects: Vec, target_commit: T, how_to_combine_messages: MessageCombinationStrategy, -) -> Result> { +) -> Result> { let mut seen_subjects = std::collections::HashSet::with_capacity(subjects.len()); if subjects.is_empty() { @@ -199,11 +199,11 @@ pub fn squash_commits<'ws, 'meta, M: RefMetadata, S: ToCommitSelector, T: ToComm let mut editor = editor; for commit_selector in subject_selectors { - let delimiter = SegmentDelimiter { + let range = StepRange { child: commit_selector, parent: commit_selector, }; - editor.disconnect_segment_from(delimiter, SelectorSet::All, SelectorSet::All, false)?; + editor.disconnect_range_from(range, SelectorSet::All, SelectorSet::All, false)?; let (selector, _) = editor.find_selectable_commit(commit_selector)?; editor.replace(selector, Step::None)?; } diff --git a/crates/but-workspace/src/commit/uncommit_changes.rs b/crates/but-workspace/src/commit/uncommit_changes.rs index 9341a5ef099..cc427d5b8ae 100644 --- a/crates/but-workspace/src/commit/uncommit_changes.rs +++ b/crates/but-workspace/src/commit/uncommit_changes.rs @@ -15,9 +15,9 @@ use crate::tree_manipulation::{ChangesSource, create_tree_without_diff}; /// The result of an uncommit_changes operation. #[derive(Debug)] -pub struct UncommitChangesOutcome<'ws, 'meta, M: RefMetadata> { +pub struct UncommitChangesOutcome<'meta, M: RefMetadata> { /// The successful rebase result - pub rebase: SuccessfulRebase<'ws, 'meta, M>, + pub rebase: SuccessfulRebase<'meta, M>, /// Selector pointing to the modified commit (with changes removed) pub commit_selector: Selector, } @@ -47,9 +47,9 @@ pub struct UncommitChangesFailure { /// The result of uncommitting changes from multiple commits. #[derive(Debug)] -pub struct UncommitChangesFromCommitsOutcome<'ws, 'meta, M: RefMetadata> { +pub struct UncommitChangesFromCommitsOutcome<'meta, M: RefMetadata> { /// The successful rebase result, present when at least one source was uncommitted. - pub rebase: Option>, + pub rebase: Option>, /// Sources that could not be uncommitted. pub failures: Vec, } @@ -64,12 +64,12 @@ struct GroupedUncommitChanges { /// /// The changes are removed from the commit's tree, effectively "uncommitting" /// them so they appear in the working directory as uncommitted changes. -pub fn uncommit_changes<'ws, 'meta, M: RefMetadata>( - editor: Editor<'ws, 'meta, M>, +pub fn uncommit_changes<'meta, M: RefMetadata>( + editor: Editor<'meta, M>, commit: impl ToCommitSelector, changes: impl IntoIterator, context_lines: u32, -) -> Result> { +) -> Result> { let (editor, commit_selector) = uncommit_changes_no_rebase(editor, commit, changes, context_lines) .map_err(|err| err.error)?; @@ -88,11 +88,11 @@ pub fn uncommit_changes<'ws, 'meta, M: RefMetadata>( /// Invalid or inapplicable grouped sources are collected in `failures`. When at /// least one source succeeds, all successful replacements are rebased once at /// the end. When no source succeeds, `rebase` is `None`. -pub fn uncommit_changes_from_commits<'ws, 'meta, M: RefMetadata>( - mut editor: Editor<'ws, 'meta, M>, +pub fn uncommit_changes_from_commits<'meta, M: RefMetadata>( + mut editor: Editor<'meta, M>, sources: impl IntoIterator, context_lines: u32, -) -> Result> { +) -> Result> { let groups = group_sources_by_commit(sources); if groups.is_empty() { bail!("No changes were provided to uncommit") @@ -162,32 +162,29 @@ pub fn uncommit_changes_from_commits<'ws, 'meta, M: RefMetadata>( Ok(UncommitChangesFromCommitsOutcome { rebase, failures }) } -struct UncommitChangesNoRebaseError<'ws, 'meta, M: RefMetadata> { - into_editor: Editor<'ws, 'meta, M>, +struct UncommitChangesNoRebaseError<'meta, M: RefMetadata> { + into_editor: Editor<'meta, M>, error: anyhow::Error, } -impl std::fmt::Display for UncommitChangesNoRebaseError<'_, '_, M> { +impl std::fmt::Display for UncommitChangesNoRebaseError<'_, M> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.error.fmt(f) } } -impl std::fmt::Debug for UncommitChangesNoRebaseError<'_, '_, M> { +impl std::fmt::Debug for UncommitChangesNoRebaseError<'_, M> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.error.fmt(f) } } -fn uncommit_changes_no_rebase<'ws, 'meta, M: RefMetadata>( - mut editor: Editor<'ws, 'meta, M>, +fn uncommit_changes_no_rebase<'meta, M: RefMetadata>( + mut editor: Editor<'meta, M>, commit: impl ToCommitSelector, changes: impl IntoIterator, context_lines: u32, -) -> std::result::Result< - (Editor<'ws, 'meta, M>, Selector), - UncommitChangesNoRebaseError<'ws, 'meta, M>, -> { +) -> std::result::Result<(Editor<'meta, M>, Selector), UncommitChangesNoRebaseError<'meta, M>> { match uncommit_changes_no_rebase_inner(&mut editor, commit, changes, context_lines) { Ok(selector) => Ok((editor, selector)), Err(error) => Err(UncommitChangesNoRebaseError { @@ -198,7 +195,7 @@ fn uncommit_changes_no_rebase<'ws, 'meta, M: RefMetadata>( } fn uncommit_changes_no_rebase_inner( - editor: &mut Editor<'_, '_, M>, + editor: &mut Editor<'_, M>, commit: impl ToCommitSelector, changes: impl IntoIterator, context_lines: u32, diff --git a/crates/but-workspace/src/divergence.rs b/crates/but-workspace/src/divergence.rs index c86c55f0d84..733be7ff627 100644 --- a/crates/but-workspace/src/divergence.rs +++ b/crates/but-workspace/src/divergence.rs @@ -52,7 +52,7 @@ impl TargetCommitRelation { pub(crate) fn get_commits_until_merge_base<'a, M: RefMetadata>( ref_name: &'a gix::refs::FullNameRef, upstream_ref_name: Cow<'a, gix::refs::FullNameRef>, - editor: &Editor<'_, '_, M>, + editor: &Editor<'_, M>, ) -> Result { let local_tip = tip_for_ref(editor, ref_name, editor.repo()) .with_context(|| format!("Could not determine tip commit for '{ref_name}'"))?; @@ -96,7 +96,7 @@ pub(crate) fn get_commits_until_merge_base<'a, M: RefMetadata>( /// /// Returns the commit ids for all provided selectors in iteration order. pub(crate) fn commit_ids_from_selectors( - editor: &Editor<'_, '_, M>, + editor: &Editor<'_, M>, selectors: impl IntoIterator, ) -> Result> { selectors @@ -119,7 +119,7 @@ pub(crate) fn commit_ids_from_selectors( /// Returns a map keyed by candidate commit id describing whether each candidate /// is historically integrated into the target branch. pub(crate) fn classify_selectors_against_target_ref( - editor: &Editor<'_, '_, M>, + editor: &Editor<'_, M>, target_ref_selector: Selector, candidate_selectors: &[Selector], ) -> Result> { @@ -142,7 +142,7 @@ pub(crate) fn classify_selectors_against_target_ref( } fn first_pick_parent( - editor: &Editor<'_, '_, M>, + editor: &Editor<'_, M>, selector: Selector, ) -> Result { let mut adjacent = editor.direct_parents(selector)?; @@ -157,7 +157,7 @@ fn first_pick_parent( } fn tip_for_ref( - editor: &Editor<'_, '_, M>, + editor: &Editor<'_, M>, ref_name: &gix::refs::FullNameRef, repo: &gix::Repository, ) -> Result { @@ -175,18 +175,16 @@ fn tip_for_ref( } fn child_on_head_first_parent_path( - editor: &Editor<'_, '_, M>, + editor: &Editor<'_, M>, reference_selector: Selector, head_id: gix::ObjectId, ) -> Result> { let head_selector = editor.select_commit(head_id)?; let mut current = Some(head_selector); while let Some(selector) = current { - let mut parents = editor.direct_parents(selector)?; - parents.sort_by_key(|(_, order)| *order); - if parents - .iter() - .any(|(parent, _)| *parent == reference_selector) + if editor + .position_parents(selector)? + .contains(&reference_selector) { return Ok((selector != head_selector).then_some(selector)); } @@ -196,7 +194,7 @@ fn child_on_head_first_parent_path( } fn find_first_parent_merge_base( - editor: &Editor<'_, '_, M>, + editor: &Editor<'_, M>, local_tip: Selector, upstream_ancestors: &HashMap, ) -> Result> { @@ -230,7 +228,7 @@ fn find_first_parent_merge_base( } fn traverse_pick_ancestor_ids( - editor: &Editor<'_, '_, M>, + editor: &Editor<'_, M>, tip: Selector, ) -> Result> { let mut out = HashMap::new(); @@ -272,7 +270,7 @@ fn traverse_pick_ancestor_ids( } fn first_parent( - editor: &Editor<'_, '_, M>, + editor: &Editor<'_, M>, selector: Selector, ) -> Result> { let mut parents = editor.direct_parents(selector)?; @@ -303,7 +301,7 @@ fn first_parent( } fn first_parent_path_until( - editor: &Editor<'_, '_, M>, + editor: &Editor<'_, M>, tip: Selector, mut stop: impl FnMut(&Selector) -> bool, ) -> Result> { diff --git a/crates/but-workspace/src/graph_manipulation.rs b/crates/but-workspace/src/graph_manipulation.rs index faca76a1f0e..4b6b756d3e2 100644 --- a/crates/but-workspace/src/graph_manipulation.rs +++ b/crates/but-workspace/src/graph_manipulation.rs @@ -5,14 +5,14 @@ use but_core::RefMetadata; use but_graph::workspace::{Stack, StackSegment}; use but_rebase::graph_rebase::{ Editor, LookupStep, Selector, Step, ToSelector, - mutate::{SegmentDelimiter, SelectorSet, SomeSelectors}, + selector::{SelectorSet, SomeSelectors, StepRange}, }; use std::collections::HashSet; /// Payload containing information about how to disconnect a segment in the graph. pub struct DisconnectParameters { /// The bounds of the segment to disconnect. - pub(crate) delimiter: SegmentDelimiter, + pub(crate) range: StepRange, /// The children of the child-most segment bound to disconnect. pub(crate) children_to_disconnect: SelectorSet, /// The parents of the parent-most segment bound to disconnect. @@ -22,9 +22,9 @@ pub struct DisconnectParameters { /// Get the right disconnect parameters for the given subject segment and source stack. /// /// This function determines which are the right parents and children to disconnect, -/// as well as the right segment delimiter to move. -pub fn get_disconnect_parameters<'ws, 'meta, M: RefMetadata>( - editor: &Editor<'ws, 'meta, M>, +/// as well as the right segment range to move. +pub fn get_disconnect_parameters<'meta, M: RefMetadata>( + editor: &Editor<'meta, M>, source_stack: &Stack, subject_segment: &StackSegment, workspace_head: Option, @@ -32,7 +32,11 @@ pub fn get_disconnect_parameters<'ws, 'meta, M: RefMetadata>( let index_of_segment = source_stack .segments .iter() - .position(|segment| segment.id == subject_segment.id) + .position(|segment| { + segment.ref_name() == subject_segment.ref_name() + && segment.commits.first().map(|c| c.id) + == subject_segment.commits.first().map(|c| c.id) + }) .context("BUG: Unable to find subject segment on source stack.")?; let subject_segment_ref_name = subject_segment @@ -51,24 +55,24 @@ pub fn get_disconnect_parameters<'ws, 'meta, M: RefMetadata>( } }; - // The delimiter for the segment we want to move, is the reference selector + // The range for the segment we want to move, is the reference selector // as the child, and the last commit inside the branch as the parent. // If the branch is empty, we take the reference selector as the parent as well. - let delimiter = SegmentDelimiter { + let range = StepRange { child: delimiter_child, parent: delimiter_parent, }; - // Disconnect the subject from the base directly below its parent-delimiter โ€” the branch's last + // Disconnect the subject from the base directly below its parent-range โ€” the branch's last // commit, or its reference when the branch is empty. The base is the first-parent edge (lowest // edge order); if the bottom commit is a merge, its higher-order parents must travel with the // subject rather than be cut. We read this from the rebase editor graph (which - // `disconnect_segment_from` validates against) rather than the workspace projection: when the + // `disconnect_range_from` validates against) rather than the workspace projection: when the // target is ahead of the merge base the projection's base segment is anonymous and resolves to // the base commit, while the editor graph keeps the target reference node between the branch and // that commit, so only the editor-graph first parent matches the edge being checked. let parents_to_disconnect = match editor - .direct_parents(delimiter.parent)? + .direct_parents(range.parent)? .into_iter() .min_by_key(|(_, order)| *order) { @@ -92,7 +96,7 @@ pub fn get_disconnect_parameters<'ws, 'meta, M: RefMetadata>( .unwrap_or(SelectorSet::None); return Ok(DisconnectParameters { - delimiter, + range, children_to_disconnect, parents_to_disconnect, }); @@ -124,7 +128,7 @@ pub fn get_disconnect_parameters<'ws, 'meta, M: RefMetadata>( let children_to_disconnect = SelectorSet::Some(selectors); Ok(DisconnectParameters { - delimiter, + range, children_to_disconnect, parents_to_disconnect, }) @@ -137,8 +141,8 @@ pub fn get_disconnect_parameters<'ws, 'meta, M: RefMetadata>( /// - If no commit parent edge is found, fall back to a `Reference` parent. /// /// If no explicit parent candidate exists, return `SelectorSet::All` as a safe fallback. -pub fn determine_parent_selector<'ws, 'meta, M: RefMetadata>( - editor: &Editor<'ws, 'meta, M>, +pub fn determine_parent_selector<'meta, M: RefMetadata>( + editor: &Editor<'meta, M>, subject_commit_selector: Selector, ) -> anyhow::Result { let mut parents = editor.direct_parents(subject_commit_selector)?; @@ -181,11 +185,11 @@ pub(crate) enum EdgeSelection { /// Returns `Ok(())` after all direct parent edges of `selector` have been /// removed from the editor graph. pub(crate) fn disconnect_selector_from_all_parents( - editor: &mut Editor<'_, '_, M>, + editor: &mut Editor<'_, M>, selector: Selector, ) -> Result<()> { - editor.disconnect_segment_from( - SegmentDelimiter { + editor.disconnect_range_from( + StepRange { child: selector, parent: selector, }, @@ -212,7 +216,7 @@ pub(crate) fn disconnect_selector_from_all_parents( /// Returns the selected neighboring selectors paired with their existing edge /// order values. pub(crate) fn selected_edges_from_set( - editor: &Editor<'_, '_, M>, + editor: &Editor<'_, M>, target: Selector, selectors: &SelectorSet, edge_selection: EdgeSelection, @@ -246,88 +250,54 @@ pub(crate) fn selected_edges_from_set( /// /// `editor` is the mutable graph editor whose edges will be recreated. /// -/// `delimiter` identifies the rebuilt segment's child-most and parent-most +/// `range` identifies the rebuilt segment's child-most and parent-most /// selectors. /// /// `children` are the previously captured child edges that should point back to -/// `delimiter.child`. If the child is already connected to `delimiter.child`, no -/// new edge is added. Otherwise, the original edge order is reused when -/// available, or the next free order is used when another parent already -/// occupies it. +/// `range.child`. If the child is already connected to `range.child`, no +/// new edge is added. Otherwise, the edge is inserted at the captured parent +/// parent number (clamped to the end), restoring the child's original parent order. /// /// `parents` are the previously captured parent edges that should be restored -/// from `delimiter.parent`, with fresh order values appended after any existing -/// parents already connected there. If a parent is already connected to -/// `delimiter.parent`, no new edge is added. Otherwise, the appended order is -/// reused when available, or advanced to the next free order on collision. +/// from `range.parent`, appended after any existing parents already +/// connected there in their captured relative order. If a parent is already +/// connected to `range.parent`, no new edge is added. /// /// Returns `Ok(())` after the captured child and parent edges have been /// reattached to the rebuilt segment. pub(crate) fn connect_segment_to_edges( - editor: &mut Editor<'_, '_, M>, - delimiter: SegmentDelimiter, + editor: &mut Editor<'_, M>, + range: StepRange, children: &[(Selector, usize)], parents: &[(Selector, usize)], ) -> Result<()> { - for (child, order) in children { + for (child, parent_number) in children { let direct_parents = editor.direct_parents(*child)?; if direct_parents .iter() - .any(|(parent, _)| *parent == delimiter.child) + .any(|(parent, _)| *parent == range.child) { continue; } - editor.add_edge( - *child, - delimiter.child, - next_available_order(direct_parents.iter().map(|(_, order)| *order), *order), - )?; + editor.insert_edge(*child, range.child, *parent_number)?; } - let parent_order_offset = editor - .direct_parents(delimiter.parent)? - .into_iter() - .map(|(_, order)| order) - .max() - .map(|max| max + 1) - .unwrap_or(0); - - for (parent, order) in parents { - let direct_parents = editor.direct_parents(delimiter.parent)?; + let mut parents = parents.to_vec(); + parents.sort_by_key(|(_, parent_number)| *parent_number); + for (parent, _) in parents { + let direct_parents = editor.direct_parents(range.parent)?; if direct_parents .iter() - .any(|(existing_parent, _)| *existing_parent == *parent) + .any(|(existing_parent, _)| *existing_parent == parent) { continue; } - let desired_order = parent_order_offset + *order; - editor.add_edge( - delimiter.parent, - *parent, - next_available_order( - direct_parents - .iter() - .map(|(_, existing_order)| *existing_order), - desired_order, - ), - )?; + editor.push_edge(range.parent, parent)?; } Ok(()) } -fn next_available_order( - existing_orders: impl Iterator, - desired_order: usize, -) -> usize { - let used_orders = existing_orders.collect::>(); - let mut order = desired_order; - while used_orders.contains(&order) { - order += 1; - } - order -} - /// Return a direct parent of `child` when `step` refers to a pick that is already connected. /// /// This is useful when rebuilding an editor segment and we want to reuse an existing @@ -343,7 +313,7 @@ fn next_available_order( /// Returns the matching direct parent selector when `step` already corresponds /// to an attached pick parent, or `None` otherwise. pub(crate) fn already_connected_parent_for_step( - editor: &Editor<'_, '_, M>, + editor: &Editor<'_, M>, child: Selector, step: &Step, ) -> Result> { @@ -363,8 +333,9 @@ pub(crate) fn already_connected_parent_for_step( /// Connect `child` to `parent_step`, reusing an existing pick node when possible. /// -/// The new edge gets the smallest currently unused parent order on `child`, which keeps -/// parent ordering stable while allowing callers to splice additional parents into a node. +/// The new edge is inserted at parent number 0: the rebuilt chain defines `child`'s +/// first-parent lane, and any parents `child` kept (a merge's side parent, parents that +/// survived a partial disconnect) shift after it. /// /// `editor` is the mutable graph editor that may reuse an existing pick or add a /// new step before creating the edge. @@ -376,7 +347,7 @@ pub(crate) fn already_connected_parent_for_step( /// /// Returns the selector of the connected parent node. pub(crate) fn connect_parent_step( - editor: &mut Editor<'_, '_, M>, + editor: &mut Editor<'_, M>, child: Selector, parent_step: Step, ) -> Result { @@ -392,17 +363,7 @@ pub(crate) fn connect_parent_step( Step::None => bail!("BUG: trying to connect to none"), }; - let used_orders = editor - .direct_parents(child)? - .into_iter() - .map(|(_, order)| order) - .collect::>(); - let mut order = 0; - while used_orders.contains(&order) { - order += 1; - } - - editor.add_edge(child, parent, order)?; + editor.insert_edge(child, parent, 0)?; Ok(parent) } @@ -415,14 +376,14 @@ pub(crate) fn connect_parent_step( /// Returns the set containing `tip` and every selector reachable from it by /// repeatedly following direct parent edges. pub(crate) fn traverse_nodes( - editor: &Editor<'_, '_, M>, + editor: &Editor<'_, M>, tip: Selector, ) -> Result> { let mut seen = HashSet::from([tip]); let mut tips = vec![tip]; while let Some(tip) = tips.pop() { - for (parent, _) in editor.direct_parents(tip)? { + for parent in editor.position_parents(tip)? { if seen.insert(parent) { tips.push(parent); } diff --git a/crates/but-workspace/src/legacy/head.rs b/crates/but-workspace/src/legacy/head.rs index 705a5c8069c..9e8e9eda9e3 100644 --- a/crates/but-workspace/src/legacy/head.rs +++ b/crates/but-workspace/src/legacy/head.rs @@ -114,7 +114,9 @@ pub fn remerged_workspace_tree_v2( #[instrument(level = "debug", skip(ctx))] pub fn remerged_workspace_commit_v2(ctx: &Context) -> Result { let repo = ctx.clone_repo_for_merging()?; - let (workspace_tree_id, stacks, target_commit) = remerged_workspace_tree_v2(ctx, &repo)?; + let (workspace_tree_id, mut stacks, target_commit) = remerged_workspace_tree_v2(ctx, &repo)?; + // Workspace-commit parents follow the metadata stack order, not HashMap iteration order. + stacks.sort_by_key(|stack| stack.order); let committer = signature_gix(SignaturePurpose::Committer); let author = signature_gix(SignaturePurpose::Author); diff --git a/crates/but-workspace/src/legacy/push.rs b/crates/but-workspace/src/legacy/push.rs index 29d5f30297b..b068cfa5e83 100644 --- a/crates/but-workspace/src/legacy/push.rs +++ b/crates/but-workspace/src/legacy/push.rs @@ -35,7 +35,6 @@ pub fn workspace_branch_and_ancestors_push( run_husky_hooks: bool, push_opts: Vec, ) -> Result { - let graph = &ws.graph; let mut to_push = IndexMap::new(); let remote_names = repo.remote_names(); @@ -73,12 +72,12 @@ pub fn workspace_branch_and_ancestors_push( } if refname_found { - to_push.insert(segment.id, segment); + to_push.insert(ref_name.to_owned(), segment); } } } - for (sidx, segment) in to_push.iter().rev() { + for segment in to_push.values().rev() { // this will always be set let Some(ref_name) = segment.ref_info.as_ref().map(|r| r.ref_name.as_ref()) else { continue; @@ -91,7 +90,7 @@ pub fn workspace_branch_and_ancestors_push( continue; } - let Some(local_sha) = graph.tip_skip_empty(*sidx) else { + let Some(local_sha) = ws.branch_resting_commit_id(ref_name) else { continue; }; @@ -116,7 +115,7 @@ pub fn workspace_branch_and_ancestors_push( repo, &remote_name, &remote_url.to_bstring().to_str_lossy(), - local_sha.id, + local_sha, &RemoteRefname::from_str(&remote_refname.as_bstr().to_str_lossy())?, run_husky_hooks, )? { @@ -130,13 +129,13 @@ pub fn workspace_branch_and_ancestors_push( let gerrit_push_args = gerrit_push_args( gerrit_mode, - local_sha.id, + local_sha, target_branch_name.as_bstr(), &push_opts, ); let push_output = push_with_askpass( repo, - local_sha.id, + local_sha, remote_refname.as_ref(), with_force, force_push_protection && !skip_force_push_protection, @@ -156,7 +155,7 @@ pub fn workspace_branch_and_ancestors_push( result.branch_sha_updates.push(( branch_name, before_sha.to_string(), - local_sha.id.to_string(), + local_sha.to_string(), )); } diff --git a/crates/but-workspace/src/legacy/stacks.rs b/crates/but-workspace/src/legacy/stacks.rs index 999fd8479b7..4898f9da0e9 100644 --- a/crates/but-workspace/src/legacy/stacks.rs +++ b/crates/but-workspace/src/legacy/stacks.rs @@ -230,7 +230,7 @@ pub fn stacks_v3( let options = ref_info::Options { project_meta: project_meta.clone(), expensive_commit_info: false, - traversal: but_graph::init::Options::limited(), + traversal: but_graph::walk::Options::limited(), ..Default::default() }; let info = match ref_name_override { @@ -312,7 +312,7 @@ pub fn stack_details_v3( ref_info::Options { project_meta: project_meta.clone(), expensive_commit_info: true, - traversal: but_graph::init::Options::limited(), + traversal: but_graph::walk::Options::limited(), ..Default::default() } } @@ -414,12 +414,10 @@ pub fn stack_details_v3( impl ui::BranchDetails { fn from_segment( Segment { - id: _, ref_info, commits: commits_unique_from_tip, commits_on_remote: commits_unique_in_remote_tracking_branch, remote_tracking_ref_name, - remote_tracking_branch_segment_id: _, // There is nothing equivalent commits_outside, metadata, diff --git a/crates/but-workspace/src/lib.rs b/crates/but-workspace/src/lib.rs index 3216a262912..671cc7616fd 100644 --- a/crates/but-workspace/src/lib.rs +++ b/crates/but-workspace/src/lib.rs @@ -61,7 +61,6 @@ pub use ref_info::{graph_to_ref_info, head_info, head_info_and_workspace, ref_in mod branch_details; pub use branch_details::{branch_details, local_commits_for_branch}; -use but_graph::{SegmentIndex, workspace::TargetCommit}; mod upstream_integration; pub use upstream_integration::{ @@ -101,23 +100,6 @@ pub struct RefInfo { /// If `None`, this is a local workspace that doesn't know when possibly pushed branches are considered integrated. /// This happens when there is a local branch checked out without a remote tracking branch. pub target_ref: Option, - /// A commit reachable by [`Self::target_ref`] which we chose to keep as base. That way we can extend the workspace - /// past its computed lower bound. - /// - /// Indeed, it's valid to not set the reference, and to only set the commit which should act as an integration base. - pub target_commit: Option, - /// The bound can be imagined as the segment from which all other commits in the workspace originate. - /// It can also be imagined to be the delimiter at the bottom beyond which nothing belongs to the workspace, - /// as antagonist to the first commit in tip of the segment with `id`, serving as first commit that is - /// inside the workspace. - /// - /// As such, it's always the longest path to the first shared commit with the target among - /// all of our stacks, or it is the first commit that is shared among all of our stacks in absence of a target. - /// One can also think of it as the starting point from which all workspace commits can be reached when - /// following all incoming connections and stopping at the tip of the workspace. - /// - /// It is `None` there is only a single stack and no target, so nothing was integrated. - pub lower_bound: Option, /// The `workspace_ref_name` is `Some(_)` and belongs to GitButler, because it had metadata attached. pub is_managed_ref: bool, /// The `workspace_ref_name` points to a commit that was specifically created by us. @@ -161,10 +143,8 @@ pub struct AncestorWorkspaceCommit { /// The commits along the first parent that are between the managed workspace reference and the managed workspace commit. /// The vec *should* not be empty, but it can be empty in practice for reasons yet to be discovered. pub commits_outside: Vec, - /// The index of the segment that actually holds the managed workspace commit. - pub segment_with_managed_commit: SegmentIndex, - /// The index of the workspace commit within the `commits` array in its parent segment. - pub commit_index_of_managed_commit: but_graph::CommitIndex, + /// The id of the managed workspace commit found in the ancestry. + pub commit_id: gix::ObjectId, } /// A representation of the commit that is the tip of the workspace i.e., usually what `HEAD` points to, diff --git a/crates/but-workspace/src/ref_info.rs b/crates/but-workspace/src/ref_info.rs index 879bbc45e85..699576e585e 100644 --- a/crates/but-workspace/src/ref_info.rs +++ b/crates/but-workspace/src/ref_info.rs @@ -1,4 +1,3 @@ -#![expect(clippy::indexing_slicing)] // TODO: rename this module to `workspace`, make it private, and pub-use all content in the top-level, as we now literally // get the workspace, while possibly processing it for use in the UI. @@ -10,7 +9,7 @@ use std::{ use bstr::{BString, ByteSlice}; use but_core::{WORKSPACE_REF_NAME, ref_metadata}; -use but_graph::{SegmentIndex, workspace::StackCommitFlags}; +use but_graph::workspace::StackCommitFlags; use gix::Repository; /// A commit with must useful information extracted from the Git commit itself. @@ -239,8 +238,7 @@ impl WorkspaceExt for but_graph::Workspace { if self.kind.has_managed_commit() { return false; } - find_ancestor_workspace_commit(&self.graph, repo, self.id, self.lower_bound_segment_id) - .is_some() + find_ancestor_workspace_commit(self, repo).is_some() } } @@ -281,7 +279,7 @@ pub struct Options<'db> { /// Project-scoped metadata used to resolve target refs and push remotes. pub project_meta: but_core::ref_metadata::ProjectMeta, /// Control how to traverse the commit-graph as the basis for the workspace conversion. - pub traversal: but_graph::init::Options, + pub traversal: but_graph::walk::Options, /// Perform expensive computations on a per-commit basis. /// /// Note that less expensive checks are still performed. @@ -290,7 +288,7 @@ pub struct Options<'db> { pub gerrit_mode: GerritMode<'db>, } -/// A segment of a commit graph, representing a set of commits exclusively. +/// A segment of the commit history, representing a set of commits exclusively. #[derive(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, @@ -303,17 +301,9 @@ pub struct Segment { /// Finally, this is `None` of the original name can be found searching upwards, finding exactly one /// named segment. pub ref_info: 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: 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, - /// The graph segment id of the remote-tracking branch (see `remote_tracking_ref_name`) associated - /// with this segment, if present. - /// Note that this id is only meaningful within the current graph instance and is not suitable to - /// permanently identify the segment, so it must not be persisted. - pub remote_tracking_branch_segment_id: 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. /// @@ -334,7 +324,7 @@ pub struct Segment { /// Read-only metadata with additional information about the branch naming the segment, /// or `None` if nothing was present. pub metadata: Option, - /// This is `true` a segment in a workspace if the entrypoint of [the traversal](but_graph::Graph::from_commit_traversal()) + /// This is `true` a segment in a workspace if the entrypoint of [the traversal](but_graph::Workspace::from_tip) /// is this segment, and the surrounding workspace is provided for context. /// /// This means one will see the entire workspace, while knowing the focus is on one specific segment. @@ -364,12 +354,10 @@ impl std::fmt::Debug for Segment { let Segment { ref_info, // Segment indices are ephemeral; keep them out of snapshot output. - id: _, commits, commits_on_remote, commits_outside, remote_tracking_ref_name, - remote_tracking_branch_segment_id: _, metadata, is_entrypoint, push_status, @@ -417,11 +405,7 @@ impl std::fmt::Debug for Segment { use anyhow::{Context as _, bail}; use but_core::{is_workspace_ref_name, ref_metadata::ValueInfo}; -use but_graph::{ - Graph, - petgraph::Direction, - workspace::{StackCommit, WorkspaceKind}, -}; +use but_graph::workspace::{StackCommit, WorkspaceKind}; use gix::prelude::ObjectIdExt; use tracing::instrument; @@ -448,13 +432,12 @@ pub fn head_info_and_workspace( meta: &impl but_core::RefMetadata, opts: Options<'_>, ) -> anyhow::Result<(RefInfo, but_graph::Workspace)> { - let graph = Graph::from_head( + let ws = but_graph::Workspace::from_head( repo, meta, opts.project_meta.clone(), opts.traversal.clone(), )?; - let ws = graph.into_workspace()?; Ok((graph_to_ref_info(&ws, repo, opts)?, ws)) } @@ -474,61 +457,49 @@ pub fn ref_info( ) -> anyhow::Result { let id = existing_ref.peel_to_id()?; let repo = id.repo; - let graph = Graph::from_commit_traversal( + let ws = but_graph::Workspace::from_tip( id, existing_ref.inner.name, meta, opts.project_meta.clone(), opts.traversal.clone(), )?; - graph_to_ref_info(&graph.into_workspace()?, repo, opts) + graph_to_ref_info(&ws, repo, opts) } pub(crate) fn find_ancestor_workspace_commit( - graph: &Graph, + workspace: &but_graph::Workspace, repo: &gix::Repository, - workspace_id: SegmentIndex, - lower_bound_segment_id: Option, ) -> Option { - let lower_bound_generation = lower_bound_segment_id.map(|sidx| graph[sidx].generation); - let mut commits_outside = Vec::new(); - let mut sidx_and_cidx = None; - graph.visit_all_segments_excluding_start_until(workspace_id, Direction::Outgoing, |s| { - if sidx_and_cidx.is_some() - || lower_bound_generation.is_some_and(|max_gen| s.generation > max_gen) - { + let mut managed_commit_id = None; + workspace.visit_commits_below_tip(|graph_commit| { + let Ok(commit) = WorkspaceCommit::from_id(graph_commit.id.attach(repo)) else { + return false; + }; + if commit.is_managed() { + managed_commit_id = Some(graph_commit.id); return true; } - for (cidx, graph_commit) in s.commits.iter().enumerate() { - let Ok(commit) = WorkspaceCommit::from_id(graph_commit.id.attach(repo)) else { - continue; - }; - if commit.is_managed() { - sidx_and_cidx = Some((s.id, cidx)); - return true; - } - commits_outside.push( - crate::ref_info::Commit::from_commit_ahead_of_workspace_commit( - commit.inner, - graph_commit, - ), - ); - } + commits_outside.push( + crate::ref_info::Commit::from_commit_ahead_of_workspace_commit( + commit.inner, + graph_commit, + ), + ); false }); - ancestor_workspace_commit_if_outside(commits_outside, sidx_and_cidx) + ancestor_workspace_commit_if_outside(commits_outside, managed_commit_id) } fn ancestor_workspace_commit_if_outside( commits_outside: Vec, - sidx_and_cidx: Option<(SegmentIndex, usize)>, + managed_commit_id: Option, ) -> Option { - let (sidx, cidx) = sidx_and_cidx?; + let commit_id = managed_commit_id?; (!commits_outside.is_empty()).then_some(AncestorWorkspaceCommit { commits_outside, - segment_with_managed_commit: sidx, - commit_index_of_managed_commit: cidx, + commit_id, }) } @@ -541,34 +512,28 @@ pub fn graph_to_ref_info( repo: &gix::Repository, opts: Options<'_>, ) -> anyhow::Result { - if workspace.graph.hard_limit_hit() { + if workspace.hard_limit_hit() { tracing::warn!(hard_limit=?opts.traversal.hard_limit, "Commit-graph traversal might be incorrect as it was stopped too early due to hard limit", ); } - let but_graph::Workspace { - graph, - id, - kind, - stacks, - target_ref, - target_commit, - metadata, - lower_bound: _, - lower_bound_segment_id, - } = workspace; + let (kind, stacks, target_ref, metadata) = ( + &workspace.kind, + &workspace.stacks, + &workspace.target_ref, + &workspace.metadata, + ); let (workspace_ref_info, is_managed_commit, ancestor_workspace_commit) = match kind { WorkspaceKind::Managed { ref_info } => (Some(ref_info), true, None), WorkspaceKind::ManagedMissingWorkspaceCommit { ref_info: ref_name } => { - let maybe_ancestor_workspace_commit = - find_ancestor_workspace_commit(graph, repo, *id, *lower_bound_segment_id); + let maybe_ancestor_workspace_commit = find_ancestor_workspace_commit(workspace, repo); (Some(ref_name), false, maybe_ancestor_workspace_commit) } - WorkspaceKind::AdHoc => (graph[*id].ref_info.as_ref(), false, None), + WorkspaceKind::AdHoc => (workspace.tip_ref_info(), false, None), }; - let is_entrypoint = graph.entrypoint()?.segment.id == *id; + let is_entrypoint = workspace.tip_is_entrypoint()?; let mut info = RefInfo { workspace_ref_info: workspace_ref_info.cloned(), symbolic_remote_names: repo @@ -576,13 +541,11 @@ pub fn graph_to_ref_info( .into_iter() .map(|n| n.into_owned().into()) .collect(), - lower_bound: *lower_bound_segment_id, stacks: stacks .iter() .map(|stack| branch::Stack::try_from_graph_stack(stack, repo)) .collect::>()?, target_ref: target_ref.clone(), - target_commit: target_commit.clone(), is_managed_ref: metadata.is_some(), is_managed_commit, ancestor_workspace_commit, @@ -596,8 +559,7 @@ pub fn graph_to_ref_info( "Found {} commit(s) on top of the workspace commit.\n\n", info.commits_outside.len() ); - let ws_commit_id = - graph[info.segment_with_managed_commit].commits[info.commit_index_of_managed_commit].id; + let ws_commit_id = info.commit_id; msg.push_str( "Run the following command in your working directory to fix this while leaving your worktree unchanged.\n", ); @@ -605,7 +567,7 @@ pub fn graph_to_ref_info( msg.push_str(&format!(" git reset --soft {ws_commit_id}")); bail!("{msg}"); } - info.compute_similarity(graph, repo, opts.expensive_commit_info)?; + info.compute_similarity(workspace, repo, opts.expensive_commit_info)?; if let GerritMode::Enabled(metadata) = opts.gerrit_mode { info.apply_gerrit_metadata(metadata)?; } @@ -793,18 +755,15 @@ impl crate::ref_info::Segment { but_graph::workspace::StackSegment { ref_info, base, - base_segment_id: _, remote_tracking_ref_name, - sibling_segment_id: _, - remote_tracking_branch_segment_id, - id, + name_projected_from_outside: _, commits, // TODO: make it visible in this this data structure. commits_outside, commits_on_remote, - commits_by_segment: _, metadata, is_entrypoint, + .. }: &but_graph::workspace::StackSegment, repo: &gix::Repository, ) -> anyhow::Result { @@ -831,9 +790,7 @@ impl crate::ref_info::Segment { .transpose()?; Ok(Self { ref_info: ref_info.clone(), - id: *id, remote_tracking_ref_name: remote_tracking_ref_name.clone(), - remote_tracking_branch_segment_id: *remote_tracking_branch_segment_id, commits, commits_on_remote, commits_outside, diff --git a/crates/but-workspace/src/ui/diff.rs b/crates/but-workspace/src/ui/diff.rs index a1b1fa97f48..34917f8d680 100644 --- a/crates/but-workspace/src/ui/diff.rs +++ b/crates/but-workspace/src/ui/diff.rs @@ -15,11 +15,9 @@ pub fn changes_in_branch( } else { // TODO: this should be (kept) in sync with branch-listing! let tip = repo.find_reference(branch)?.peel_to_commit()?.id; - workspace.target_ref.as_ref().and_then(|target| { + workspace.target_ref.as_ref().and_then(|_target| { // NOTE: Can't do merge-base computation in graph as `branch` might not be contained in it. - let base = workspace - .tip_commit_by_segment_id(target.segment_index) - .map(|c| c.id)?; + let base = workspace.effective_target_commit_id()?; // This works because the lower-bound itself is the merge-base // between all applicable targets and the workspace branches. repo.merge_base(tip, base) diff --git a/crates/but-workspace/src/ui/mod.rs b/crates/but-workspace/src/ui/mod.rs index 428332c2d46..8de93628688 100644 --- a/crates/but-workspace/src/ui/mod.rs +++ b/crates/but-workspace/src/ui/mod.rs @@ -26,7 +26,7 @@ use crate::{ // here to keep `but_workspace::ui::CommitState` (and the SDK schema) stable. pub use but_core::ui::CommitState; -/// Commit that is a part of a [`StackBranch`](gitbutler_stack::StackBranch) and, as such, containing state derived in relation to the specific branch. +/// Commit that is a part of a legacy `StackBranch` and, as such, containing state derived in relation to the specific branch. #[derive(Clone, Serialize)] #[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] diff --git a/crates/but-workspace/src/ui/ref_info.rs b/crates/but-workspace/src/ui/ref_info.rs index 4fe331f45fa..bb7f5249f9c 100644 --- a/crates/but-workspace/src/ui/ref_info.rs +++ b/crates/but-workspace/src/ui/ref_info.rs @@ -104,13 +104,14 @@ but_schemars::register_sdk_type!(Target); impl Target { fn for_ui( - but_graph::workspace::TargetRef { - ref_name, - segment_index: _, - commits_ahead, - }: but_graph::workspace::TargetRef, + target: but_graph::workspace::TargetRef, remote_names: &gix::remote::Names, ) -> anyhow::Result { + let but_graph::workspace::TargetRef { + ref_name, + commits_ahead, + .. + } = target; Ok(Target { remote_tracking_ref: RemoteTrackingReference::for_ui(ref_name, remote_names)?, commits_ahead, @@ -183,8 +184,6 @@ impl inner::RefInfo { symbolic_remote_names, stacks, target_ref, - target_commit: _, - lower_bound: _, is_managed_ref, is_managed_commit, ancestor_workspace_commit: _, @@ -293,7 +292,7 @@ pub struct Segment { /// Read-only metadata with additional information about the branch naming the segment, /// or `None` if nothing was present. pub metadata: Option, - /// This is `true` a segment in a workspace if the entrypoint of [the traversal](but_graph::Graph::from_commit_traversal) + /// This is `true` a segment in a workspace if the entrypoint of [the traversal](but_graph::Workspace::from_tip) /// is this segment, and the surrounding workspace is provided for context. /// /// This means one will see the entire workspace, while knowing the focus is on one specific segment. @@ -321,9 +320,7 @@ impl Segment { fn for_ui( crate::ref_info::Segment { ref_info, - id: _, remote_tracking_ref_name, - remote_tracking_branch_segment_id: _, commits, commits_on_remote, commits_outside, diff --git a/crates/but-workspace/src/upstream_integration.rs b/crates/but-workspace/src/upstream_integration.rs index c34931cca9a..ea7d5022dba 100644 --- a/crates/but-workspace/src/upstream_integration.rs +++ b/crates/but-workspace/src/upstream_integration.rs @@ -10,7 +10,8 @@ use but_rebase::{ commit::DateMode, graph_rebase::{ Editor, LookupStep, Pick, Selector, Step, SuccessfulRebase, ToSelector, - mutate::{InsertSide, RelativeTo, SegmentDelimiter, SelectorSet}, + mutate::InsertSide, + selector::{RelativeTo, SelectorSet, StepRange}, }, }; @@ -48,13 +49,13 @@ pub struct ReviewIntegrationHint { } /// The outcome of integrating upstream -pub struct IntegrateUpstreamOutcome<'ws, 'meta, M: RefMetadata> { +pub struct IntegrateUpstreamOutcome<'meta, M: RefMetadata> { /// The updated workspace metadata. pub ws_meta: Option, /// The updated project metadata. pub project_meta: ProjectMeta, /// The rebased outcome. - pub rebase: SuccessfulRebase<'ws, 'meta, M>, + pub rebase: SuccessfulRebase<'meta, M>, } #[derive(Clone, Debug)] @@ -147,26 +148,26 @@ struct Stack { /// and those that are. These edges get replaced with edges to `target.ref` /// - We replace all steps marked as `content_integrated` that are not /// `historically_integrated` with `None` steps. -pub fn integrate_upstream<'ws, 'meta, M: RefMetadata>( - workspace: &'ws mut but_graph::Workspace, +pub fn integrate_upstream<'meta, M: RefMetadata>( + workspace: &but_graph::Workspace, meta: &'meta mut M, project_meta: ProjectMeta, repo: &gix::Repository, updates: Vec, -) -> Result> { +) -> Result> { integrate_upstream_with_hints(workspace, meta, project_meta, repo, updates, &[]) } /// Like [`integrate_upstream()`], but accepts merged-review-derived integration /// anchors to classify additional integrated history. -pub fn integrate_upstream_with_hints<'ws, 'meta, M: RefMetadata>( - workspace: &'ws mut but_graph::Workspace, +pub fn integrate_upstream_with_hints<'meta, M: RefMetadata>( + workspace: &but_graph::Workspace, meta: &'meta mut M, project_meta: ProjectMeta, repo: &gix::Repository, updates: Vec, review_hints: &[ReviewIntegrationHint], -) -> Result> { +) -> Result> { if matches!(workspace.kind, but_graph::workspace::WorkspaceKind::AdHoc) && workspace.ref_name().is_none() { @@ -183,11 +184,10 @@ pub fn integrate_upstream_with_hints<'ws, 'meta, M: RefMetadata>( .context("Cannot update a workspace with no target ref")?; let target_ref_commit = repo.find_reference(&target_ref.ref_name)?.id(); - let entrypoint = workspace.graph.entrypoint()?; - let head_commit = entrypoint - .commit() + let head_commit = workspace + .entrypoint_commit_id()? .context("Cannot update workspace without head commit")?; - let head_commit = repo.find_commit(head_commit.id)?; + let head_commit = repo.find_commit(head_commit)?; let head_commit_id = head_commit.id; let head_is_workspace_commit = is_managed_workspace_by_message(head_commit.message_raw()?); let direct_checkout_head_ref_name = if head_is_workspace_commit { @@ -198,7 +198,12 @@ pub fn integrate_upstream_with_hints<'ws, 'meta, M: RefMetadata>( // The editor contains every segment in the graph; the target ref's segment // is reachable from HEAD and so is mutable by default. - let mut editor = Editor::create(workspace, meta, repo)?; + let mut editor = Editor::create( + workspace.commit_graph(), + workspace.project_meta(), + meta, + repo, + )?; let updates_with_selectors = updates .iter() @@ -253,12 +258,12 @@ pub fn integrate_upstream_with_hints<'ws, 'meta, M: RefMetadata>( while let Some(tip) = tips.pop() { for c in editor - .direct_children(tip)? + .position_children(tip)? .iter() - .filter_map(|(c, _)| stack.nodes.contains_key(c).then_some(*c)) + .filter(|c| stack.nodes.contains_key(*c)) { - if seen.insert(c) { - tips.push(c); + if seen.insert(*c) { + tips.push(*c); } } } @@ -358,7 +363,7 @@ pub fn integrate_upstream_with_hints<'ws, 'meta, M: RefMetadata>( // Only parent is the old target sha, and that's not the latest tip of the target ref. // We need to reparent it onto the latest target ref. editor.remove_edges(workspace_commit_selector, *parent_selector)?; - editor.add_edge( + editor.insert_edge( workspace_commit_selector, target_ref_selector, *parent_order, @@ -366,7 +371,7 @@ pub fn integrate_upstream_with_hints<'ws, 'meta, M: RefMetadata>( } [] if !fully_integrated_workspace_parents.is_empty() => { // Orphaned workspace, reparent onto the target ref. - editor.add_edge(workspace_commit_selector, target_ref_selector, 0)?; + editor.insert_edge(workspace_commit_selector, target_ref_selector, 0)?; } _ => {} } @@ -395,7 +400,7 @@ pub fn integrate_upstream_with_hints<'ws, 'meta, M: RefMetadata>( Step::Pick(Pick::new_untracked_pick(merge_commit)), insert_side, )?; - editor.add_edge(merge_commit, target_ref_selector, 1)?; + editor.insert_edge(merge_commit, target_ref_selector, 1)?; } else { let mut edges_to_replace = HashSet::new(); @@ -438,11 +443,11 @@ pub fn integrate_upstream_with_hints<'ws, 'meta, M: RefMetadata>( for (child, parent) in edges_to_replace { let removed = editor.remove_edges(child, parent)?; - // Add back the lowest ordered parent that was removed. + // Put the target ref where the lowest removed parent number was. // We could add back multiple, but it's likely unintentional // that there were two parents in the first place. - if let Some(removed) = removed.iter().min() { - editor.add_edge(child, target_ref_selector, *removed)?; + if let Some(parent_number) = removed.iter().min() { + editor.insert_edge(child, target_ref_selector, *parent_number)?; } } } @@ -458,10 +463,10 @@ pub fn integrate_upstream_with_hints<'ws, 'meta, M: RefMetadata>( } #[allow(clippy::too_many_arguments)] -fn collect_stacks<'ws, 'meta, M: RefMetadata>( +fn collect_stacks<'meta, M: RefMetadata>( head_commit: gix::Commit<'_>, head_is_workspace_commit: bool, - editor: &Editor<'ws, 'meta, M>, + editor: &Editor<'meta, M>, from_target_sha: HashSet, from_target_ref: HashSet, target_sha: gix::ObjectId, @@ -471,9 +476,9 @@ fn collect_stacks<'ws, 'meta, M: RefMetadata>( ) -> Result> { let mut stacks = if head_is_workspace_commit { editor - .direct_parents(head_commit.id)? + .position_parents(head_commit.id)? .into_iter() - .map(|(c, _)| Stack { + .map(|c| Stack { to_merge: false, nodes: HashMap::from([(c, AnnotatedNode::new())]), heads: HashSet::from([c]), @@ -493,7 +498,7 @@ fn collect_stacks<'ws, 'meta, M: RefMetadata>( let mut tips = stack.nodes.keys().copied().collect::>(); while let Some(tip) = tips.pop() { - for (parent, _order) in editor.direct_parents(tip)? { + for parent in editor.position_parents(tip)? { if from_target_sha.contains(&parent) { continue; } @@ -545,15 +550,18 @@ fn collect_stacks<'ws, 'meta, M: RefMetadata>( for node in nodes.keys() { if editor - .direct_parents(*node)? + .position_parents(*node)? .iter() - .all(|(p, _)| !nodes.contains_key(p)) + .all(|p| !nodes.contains_key(p)) { bottoms.insert(*node); } } for (node, attrs) in nodes.iter_mut() { + // Reachability also marks REFERENCES: a ref chain reachable from the target lies + // on integrated history (its anchor commit is integrated), so this is anchor-based + // integration expressed through the graph walk. if from_target_ref.contains(node) { attrs.historically_integrated = true; } @@ -597,7 +605,7 @@ fn collect_stacks<'ws, 'meta, M: RefMetadata>( let mut traversed_commits = false; 'traversal: while let Some(tip) = tips.pop() { - for (parent, _) in editor.direct_parents(tip)? { + for parent in editor.position_parents(tip)? { let Some(attrs) = stack.nodes.get(&parent) else { continue; }; @@ -630,7 +638,6 @@ fn collect_stacks<'ws, 'meta, M: RefMetadata>( *r_sel, r_name.as_ref(), &reference_nodes, - &from_target_ref, target_sha, target_ref_name, target_ref_commit, @@ -661,12 +668,11 @@ fn collect_stacks<'ws, 'meta, M: RefMetadata>( /// target, and preserves an empty branch if it sits on top of another local /// branch that is not itself target-integrated. #[allow(clippy::too_many_arguments)] -fn empty_local_reference_remote_tip_integrated<'ws, 'meta, M: RefMetadata>( - editor: &Editor<'ws, 'meta, M>, +fn empty_local_reference_remote_tip_integrated<'meta, M: RefMetadata>( + editor: &Editor<'meta, M>, selector: Selector, ref_name: &gix::refs::FullNameRef, reference_nodes: &HashMap, - from_target_ref: &HashSet, target_sha: gix::ObjectId, target_ref_name: &gix::refs::FullNameRef, target_ref_commit: gix::ObjectId, @@ -674,10 +680,14 @@ fn empty_local_reference_remote_tip_integrated<'ws, 'meta, M: RefMetadata>( if ref_name.category() != Some(gix::refs::Category::LocalBranch) { return Ok(false); } - if editor.direct_parents(selector)?.iter().any(|(parent, _)| { + // An empty branch sitting on another local branch is that stack's forward-going tip and + // survives the cleanup โ€” unless the parent branch literally rests at the target position. + // (An earlier `!from_target_ref.contains(parent)` conjunct here was constant-true under the + // merge-bypass rule โ€” workspace-lane ref nodes were never target-reachable โ€” so the live + // semantics were always just the points-to-target check.) + if editor.position_parents(selector)?.iter().any(|parent| { reference_nodes.get(parent).is_some_and(|parent_ref| { parent_ref.category() == Some(gix::refs::Category::LocalBranch) - && !from_target_ref.contains(parent) && !reference_points_to_target( editor.repo(), parent_ref.as_ref(), @@ -761,7 +771,7 @@ fn should_delete_integrated_local_branch(ref_name: &gix::refs::FullNameRef) -> b /// Commits above the hinted head are intentionally left local, which lets a /// branch keep extra post-merge commits while dropping the already-merged prefix. fn apply_review_integration_hints( - editor: &Editor<'_, '_, M>, + editor: &Editor<'_, M>, stack: &mut Stack, review_hints: &[ReviewIntegrationHint], ) -> Result<()> { @@ -815,7 +825,7 @@ fn apply_review_integration_hints( /// repeated ancestor walks while preserving independent matched branches in a /// multi-head stack. fn highest_review_heads( - editor: &Editor<'_, '_, M>, + editor: &Editor<'_, M>, stack: &Stack, matching_heads: &[Selector], ) -> Result> { @@ -857,8 +867,8 @@ fn highest_review_heads( /// expected parentages after mutations in the editor. /// /// Prefer using the selectors if possible. -fn commit_ids<'ws, 'meta, M: RefMetadata>( - editor: &Editor<'ws, 'meta, M>, +fn commit_ids<'meta, M: RefMetadata>( + editor: &Editor<'meta, M>, selectors: impl IntoIterator, ) -> Result> { selectors @@ -876,7 +886,7 @@ fn commit_ids<'ws, 'meta, M: RefMetadata>( } fn selector_commit_id( - editor: &Editor<'_, '_, M>, + editor: &Editor<'_, M>, selector: Selector, ) -> Result> { Ok(match editor.lookup_step(selector)? { @@ -902,10 +912,10 @@ fn selector_commit_id( /// and point it at the latest target commit. /// /// The old checkout reference can be on the target ancestry path. Before repointing the step to -/// the target tip, `disconnect_segment_from()` rewires its children around the old reference to +/// the target tip, `disconnect_range_from()` rewires its children around the old reference to /// preserve the existing graph and avoid introducing a cycle. fn replace_direct_checkout_ref_with_fallback( - editor: &mut Editor<'_, '_, M>, + editor: &mut Editor<'_, M>, repo: &gix::Repository, head_ref_name: &gix::refs::FullNameRef, target_tip_selector: Selector, @@ -918,8 +928,8 @@ fn replace_direct_checkout_ref_with_fallback( Step::new_reference(fallback_ref_name.clone()), )?; - editor.disconnect_segment_from( - SegmentDelimiter { + editor.disconnect_range_from( + StepRange { child: head_ref_selector, parent: head_ref_selector, }, @@ -928,13 +938,13 @@ fn replace_direct_checkout_ref_with_fallback( false, )?; preserve_pick_parents(editor, target_tip_selector)?; - editor.add_edge(head_ref_selector, target_tip_selector, 0)?; + editor.insert_edge(head_ref_selector, target_tip_selector, 0)?; Ok((head_ref_selector, fallback_ref_name)) } fn preserve_pick_parents( - editor: &mut Editor<'_, '_, M>, + editor: &mut Editor<'_, M>, selector: Selector, ) -> Result<()> { let Step::Pick(mut pick) = editor.lookup_step(selector)? else { diff --git a/crates/but-workspace/src/workspace.rs b/crates/but-workspace/src/workspace.rs index 16213be04f7..53e0d793dd6 100644 --- a/crates/but-workspace/src/workspace.rs +++ b/crates/but-workspace/src/workspace.rs @@ -87,13 +87,33 @@ pub struct DetailedGraphWorkspace { pub stacks: Vec, } +/// Preview the workspace as it will look after `rebase` materializes, without materializing: +/// project the rebase's already-mutated commit graph directly, serving its pending ref edits +/// and entrypoint from the overlay โ€” no repository rewalk. +pub fn overlayed_workspace( + workspace: &but_graph::Workspace, + rebase: &but_rebase::graph_rebase::SuccessfulRebase<'_, M>, +) -> Result { + workspace.preview_from_commit_graph( + rebase.arena().clone(), + rebase.repo(), + rebase.meta(), + rebase.rebase_overlay()?, + ) +} + /// A detailed graph workspace pub fn detailed_graph_workspace( - workspace: &mut but_graph::Workspace, + workspace: &but_graph::Workspace, meta: &mut M, repo: &gix::Repository, ) -> Result { - let editor = Editor::create(workspace, meta, repo)?; + let editor = Editor::create( + workspace.commit_graph(), + workspace.project_meta(), + meta, + repo, + )?; let ws = editor.graph_workspace()?; Ok(DetailedGraphWorkspace { @@ -106,13 +126,13 @@ pub fn detailed_graph_workspace( } fn stack_rows( - editor: &Editor<'_, '_, M>, + editor: &Editor<'_, M>, stack: &but_rebase::graph_rebase::Subgraph, reference_status: &HashMap, commit_state: &HashMap, ) -> Result { let mut visible_nodes = HashSet::new(); - for selector in &stack.nodes { + for selector in &stack.entries { if is_visible_step(editor, *selector)? { visible_nodes.insert(*selector); } @@ -120,7 +140,7 @@ fn stack_rows( let parents_by_node = visible_nodes .iter() .copied() - .map(|node| Ok((node, visible_parents(editor, &stack.nodes, node)?))) + .map(|node| Ok((node, visible_parents(editor, &stack.entries, node)?))) .collect::>>()?; // Seed the traversal from the stack's visible tips (nodes no visible child @@ -175,7 +195,7 @@ fn stack_rows( }) } -fn is_visible_step(editor: &Editor<'_, '_, M>, selector: Selector) -> Result { +fn is_visible_step(editor: &Editor<'_, M>, selector: Selector) -> Result { Ok(match editor.lookup_step(selector)? { Step::Pick(_) => true, Step::Reference { refname, .. } => { @@ -186,20 +206,19 @@ fn is_visible_step(editor: &Editor<'_, '_, M>, selector: Selecto } fn visible_parents( - editor: &Editor<'_, '_, M>, + editor: &Editor<'_, M>, stack_nodes: &HashSet, selector: Selector, ) -> Result> { fn walk( - editor: &Editor<'_, '_, M>, + editor: &Editor<'_, M>, stack_nodes: &HashSet, selector: Selector, seen: &mut HashSet, out: &mut Vec, ) -> Result<()> { - let mut parents = editor.direct_parents(selector)?; - parents.sort_by_key(|(_, order)| *order); - for (parent, _) in parents { + let parents = editor.position_parents(selector)?; + for parent in parents { if !stack_nodes.contains(&parent) || !seen.insert(parent) { continue; } @@ -219,10 +238,7 @@ fn visible_parents( /// Deterministic ordering key for seed tips: commits before references, then by /// id / refname. Mirrors `graph_rebase::testing::compare_heads`. -fn seed_key( - editor: &Editor<'_, '_, M>, - selector: Selector, -) -> Result<(u8, String)> { +fn seed_key(editor: &Editor<'_, M>, selector: Selector) -> Result<(u8, String)> { Ok(match editor.lookup_step(selector)? { Step::Pick(Pick { id, .. }) => (0, id.to_string()), Step::Reference { refname, .. } => (1, refname.as_bstr().to_string()), @@ -384,7 +400,7 @@ fn reference_segments( } fn row_data( - editor: &Editor<'_, '_, M>, + editor: &Editor<'_, M>, selector: Selector, reference_status: &HashMap, commit_state: &HashMap, diff --git a/crates/but-workspace/src/worktree.rs b/crates/but-workspace/src/worktree.rs index 990f482058a..3467b285f26 100644 --- a/crates/but-workspace/src/worktree.rs +++ b/crates/but-workspace/src/worktree.rs @@ -15,7 +15,8 @@ use gix::prelude::ObjectIdExt; /// behind the rebase result so callers can compute conflicts before /// materialization, including during dry-runs. pub fn worktree_conflicts_for_rebase( - rebase: &SuccessfulRebase<'_, '_, M>, + workspace: &but_graph::Workspace, + rebase: &SuccessfulRebase<'_, M>, ) -> Result> { let repo = rebase.repo(); let current_head_tree = repo.head_tree_id_or_empty()?.detach(); @@ -24,14 +25,21 @@ pub fn worktree_conflicts_for_rebase( return Ok(Vec::new()); } - let preview_workspace = rebase.overlayed_graph()?.into_workspace()?; + let preview_workspace = crate::workspace::overlayed_workspace(workspace, rebase)?; let resulting_head = preview_workspace - .graph - .entrypoint()? - .commit() + .entrypoint_commit_id()? .context("Cannot compute worktree conflicts without a resulting workspace head")?; + // The overlay can still resolve the entrypoint to its PRE-rebase commit (ref + // edits apply at materialization); follow the rebase's own mapping so the + // conflict simulation sees the rewritten head, not the old tree. + let resulting_head = rebase + .history + .commit_mappings() + .get(&resulting_head) + .copied() + .unwrap_or(resulting_head); let resulting_head_tree = - Commit::from_id(resulting_head.id.attach(repo))?.tree_id_or_auto_resolution()?; + Commit::from_id(resulting_head.attach(repo))?.tree_id_or_auto_resolution()?; let (merge_options, _) = repo.merge_options_no_rewrites_fail_fast()?; let conflict_kind = TreatAsUnresolved::git(); diff --git a/crates/but-workspace/tests/fixtures/scenario/shared.sh b/crates/but-workspace/tests/fixtures/scenario/shared.sh index 674f3529153..51d1bcc4d8a 100644 --- a/crates/but-workspace/tests/fixtures/scenario/shared.sh +++ b/crates/but-workspace/tests/fixtures/scenario/shared.sh @@ -67,11 +67,14 @@ function create_workspace_commit_once() { fi fi - git checkout -b gitbutler/workspace - if [ $# == 1 ] || [ $# == 0 ]; then - commit "$workspace_commit_subject" + if [ $# -gt 1 ]; then + # First arg becomes the workspace commit's first parent; the rest merge in order. + git checkout "$1" + git checkout -b gitbutler/workspace + git merge --no-ff -m "$workspace_commit_subject" "${@:2}" else - git merge --no-ff -m "$workspace_commit_subject" "${@}" + git checkout -b gitbutler/workspace + commit "$workspace_commit_subject" fi } diff --git a/crates/but-workspace/tests/fixtures/scenario/single-branch-commit-with-empties.sh b/crates/but-workspace/tests/fixtures/scenario/single-branch-commit-with-empties.sh new file mode 100644 index 00000000000..7feb1119fff --- /dev/null +++ b/crates/but-workspace/tests/fixtures/scenario/single-branch-commit-with-empties.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +### Description +# Single-branch (ad-hoc) mode: a commit-owning branch with two empty branches +# stacked on its tip, over a base branch. +# single-branch-fixture (base) <- commit-branch (owns 1 commit) <- empty-low <- empty-top +# HEAD on empty-top; empty-low and empty-top both point at commit-branch's tip. +set -eu -o pipefail + +source "${BASH_SOURCE[0]%/*}/shared.sh" + +git init +commit-file base base +git branch -M single-branch-fixture + +git checkout -b commit-branch +commit-file c commit + +git branch empty-low +git checkout -b empty-top diff --git a/crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-single-stack-double-stack.sh b/crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-single-stack-double-stack.sh index fa1b4a1a196..4f514899b84 100644 --- a/crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-single-stack-double-stack.sh +++ b/crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-single-stack-double-stack.sh @@ -20,4 +20,6 @@ git checkout B commit B git checkout B -b C commit C -create_workspace_commit_once A C +# Reverse arg order on purpose: this fixture must keep parent[0] diverging from the +# metadata stack order, to exercise the *_display_order_follows_workspace_parents tests. +create_workspace_commit_once C A diff --git a/crates/but-workspace/tests/fixtures/scenario/ws-with-empty-stack-target-advanced.sh b/crates/but-workspace/tests/fixtures/scenario/ws-with-empty-stack-target-advanced.sh index d84d98a006b..907ae3b9e02 100755 --- a/crates/but-workspace/tests/fixtures/scenario/ws-with-empty-stack-target-advanced.sh +++ b/crates/but-workspace/tests/fixtures/scenario/ws-with-empty-stack-target-advanced.sh @@ -20,7 +20,7 @@ git branch B git checkout -b A commit A git checkout B -create_workspace_commit_once A B +create_workspace_commit_once B A # Advance the target past the workspace base while leaving `gitbutler/target` at the base. git checkout main diff --git a/crates/but-workspace/tests/fixtures/scenario/ws-with-empty-stack.sh b/crates/but-workspace/tests/fixtures/scenario/ws-with-empty-stack.sh index 25479b00248..be7dd13bd4c 100644 --- a/crates/but-workspace/tests/fixtures/scenario/ws-with-empty-stack.sh +++ b/crates/but-workspace/tests/fixtures/scenario/ws-with-empty-stack.sh @@ -15,4 +15,6 @@ git branch B git checkout -b A commit A git checkout B -create_workspace_commit_once A B +# Reverse arg order on purpose: keep the empty branch B as parent[0] (diverging from +# metadata) for the *_display_order_follows_workspace_parents / empty-move tests. +create_workspace_commit_once B A diff --git a/crates/but-workspace/tests/workspace/branch/apply_unapply.rs b/crates/but-workspace/tests/workspace/branch/apply_unapply.rs index 514eb959bda..a5826eb52f6 100644 --- a/crates/but-workspace/tests/workspace/branch/apply_unapply.rs +++ b/crates/but-workspace/tests/workspace/branch/apply_unapply.rs @@ -11,8 +11,7 @@ use but_core::{ ref_metadata::{StackId, StackKind, WorkspaceCommitRelation::Outside}, }; use but_graph::{ - Graph, - init::{Options, Overlay, Tip}, + walk::{Options, Overlay, Seed}, workspace::WorkspaceKind, }; use but_testsupport::{ @@ -60,7 +59,7 @@ fn assert_worktree_files(repo: &gix::Repository, present: &[&str], absent: &[&st #[test] fn operation_denied_on_improper_workspace() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-ws-commit-one-stack-ws-advanced", |_meta| {}, @@ -74,7 +73,6 @@ fn operation_denied_on_improper_workspace() -> anyhow::Result<()> { "#]] ); - let ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -136,13 +134,12 @@ fn unapply_tip_of_ad_hoc_branch_is_an_error() -> anyhow::Result<()> { "#]] ); - let ws = but_graph::Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &meta, but_core::ref_metadata::ProjectMeta::default(), - but_graph::init::Options::default(), - )? - .into_workspace()?; + but_graph::walk::Options::default(), + )?; // a normal checked-out branch is still an ad-hoc workspace without workspace metadata snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -190,14 +187,13 @@ fn unapply_branch_from_named_ad_hoc_workspace_affects_metadata() -> anyhow::Resu let a2_ref = r("refs/heads/A2"); let a2_tip = repo.rev_parse_single("A2")?; - let ws = but_graph::Graph::from_commit_traversal( + let ws = but_graph::Workspace::from_tip( a2_tip, a2_ref.to_owned(), &meta, but_core::ref_metadata::ProjectMeta::default(), - but_graph::init::Options::default(), - )? - .into_workspace()?; + but_graph::walk::Options::default(), + )?; // a normal checked-out branch is still an ad-hoc workspace without workspace metadata snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -287,7 +283,7 @@ fn unapply_branch_from_named_ad_hoc_workspace_affects_metadata() -> anyhow::Resu #[test] fn ws_ref_no_ws_commit_two_virtual_stacks_on_same_commit_apply_dependent_first() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-no-ws-commit-one-stack-one-branch", |meta| { @@ -303,7 +299,6 @@ fn ws_ref_no_ws_commit_two_virtual_stacks_on_same_commit_apply_dependent_first() ); // We know a stack, but nothing is actually in the workspace. - let ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -529,7 +524,7 @@ mod workspace_disposition { #[test] fn prevent_unnecessary_workspace_reference_checks_out_last_real_stack() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-ws-commit-two-stacks", |meta| { @@ -537,7 +532,6 @@ mod workspace_disposition { add_stack_with_segments(meta, 2, "B", StackState::InWorkspace, &[]); }, )?; - let ws = graph.into_workspace()?; let out = but_workspace::branch::unapply( r("refs/heads/A"), @@ -548,7 +542,7 @@ mod workspace_disposition { )?; // with one real stack left, the workspace ref is deleted and the remaining stack is checked out snapbox::assert_data_eq!( - out.to_debug(), + &out.to_debug(), snapbox::str![[r#" Outcome { workspace_changed: true, @@ -600,7 +594,7 @@ Outcome { ), )?; snapbox::assert_data_eq!( - out.to_debug(), + &out.to_debug(), snapbox::str![[r#" Outcome { workspace_changed: true, @@ -655,13 +649,12 @@ Outcome { |_meta| {}, )?; - let ws = Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta), standard_traversal_options(), - )? - .into_workspace()?; + )?; let out = but_workspace::branch::apply(r("refs/heads/A"), ws, &repo, &mut meta, apply_options())?; let ws = out.workspace; @@ -684,7 +677,7 @@ Outcome { )?; // deleting the workspace reference should switch to the local tracking branch of the target snapbox::assert_data_eq!( - out.to_debug(), + &out.to_debug(), snapbox::str![[r#" Outcome { workspace_changed: true, @@ -718,13 +711,12 @@ Outcome { |_meta| {}, )?; - let ws = Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta), standard_traversal_options(), - )? - .into_workspace()?; + )?; let out = but_workspace::branch::apply(r("refs/heads/A"), ws, &repo, &mut meta, apply_options())?; let ws = out.workspace; @@ -739,7 +731,7 @@ Outcome { ), )?; snapbox::assert_data_eq!( - out.to_debug(), + &out.to_debug(), snapbox::str![[r#" Outcome { workspace_changed: true, @@ -773,13 +765,12 @@ Outcome { |_meta| {}, )?; - let ws = Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta), standard_traversal_options(), - )? - .into_workspace()?; + )?; let out = but_workspace::branch::apply(r("refs/heads/A"), ws, &repo, &mut meta, apply_options())?; let ws = out.workspace; @@ -829,7 +820,7 @@ Outcome { #[test] fn keep_workspace_commit_with_last_stack_removed() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-ws-commit-one-stack", |meta| { @@ -847,7 +838,6 @@ Outcome { "#]] ); - let ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -916,36 +906,35 @@ Outcome { add_stack_with_segments(&mut meta, 1, "virtual-base", StackState::InWorkspace, &[]); add_stack_with_segments(&mut meta, 2, "A", StackState::InWorkspace, &[]); add_stack_with_segments(&mut meta, 3, "B", StackState::InWorkspace, &[]); - let ws = Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta), standard_traversal_options(), - )? - .into_workspace()?; + )?; assert!( ws.kind.has_managed_commit(), "fixture starts with a managed workspace commit" ); let repo_log = visualize_commit_graph_all(&repo)?; - - snapbox::assert_data_eq!( - repo_log, - snapbox::str![[r#" -* c49e4d8 (HEAD -> gitbutler/workspace) GitButler Workspace Commit + { + snapbox::assert_data_eq!( + repo_log, + snapbox::str![[r#" +* 3147679 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -| * 09d8e52 (A) A -* | c813d8d (B) B +| * c813d8d (B) B +* | 09d8e52 (A) A |/ * 85efbe4 (origin/main, virtual-base, main) M "#]] - .raw() - ); - snapbox::assert_data_eq!( - graph_workspace(&ws).to_string(), - snapbox::str![[r#" + .raw() + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 โ”œโ”€โ”€ โ‰ก๐Ÿ“™:virtual-base on 85efbe4 {1} โ”‚ โ””โ”€โ”€ ๐Ÿ“™:virtual-base @@ -957,8 +946,8 @@ Outcome { โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] - ); - + ); + } Ok((tmp, repo, meta, ws)) } } @@ -987,13 +976,12 @@ fn main_with_advanced_remote_tracking_branch() -> anyhow::Result<()> { "refs/heads/gitbutler/workspace".try_into()?, ref_metadata::Workspace::default(), )); - let graph = Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &vb_version_cannot_have_remotes, ref_metadata::ProjectMeta::default(), Options::limited(), )?; - let ws = graph.into_workspace()?; // note how the remote isn't interesting as we have no target configured, nor an extra target. snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -1093,7 +1081,7 @@ Outcome { #[test] fn unapply_remotely_tracked_tip_of_multi_segment_stack_can_delete_workspace_ref() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "no-ws-ref-stack-and-dependent-branch", |_meta| {}, @@ -1115,7 +1103,6 @@ fn unapply_remotely_tracked_tip_of_multi_segment_stack_can_delete_workspace_ref( )?; } - let ws = graph.into_workspace()?; let out = but_workspace::branch::apply(r("refs/heads/B"), ws, &repo, &mut meta, apply_options())?; let ws = out.workspace; @@ -1179,7 +1166,7 @@ Outcome { #[test] fn workspace_with_out_of_ws_ref_and_anon_stack() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "advanced-stack-and-unnamed-stack-in-workspace", |meta| { @@ -1190,10 +1177,11 @@ fn workspace_with_out_of_ws_ref_and_anon_stack() -> anyhow::Result<()> { visualize_commit_graph_all(&repo)?, snapbox::str![[r#" * d03b217 (feature) F1 -| * dd3b979 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +| * db5f9ad (HEAD -> gitbutler/workspace) GitButler Workspace Commit | |\ -| * | d6bdeab missing-name -|/ / +| | * d6bdeab missing-name +| |/ +|/| | | * 5121eb9 (outside) advanced-outside | |/ | * 67c6397 advanced-inside @@ -1204,18 +1192,17 @@ fn workspace_with_out_of_ws_ref_and_anon_stack() -> anyhow::Result<()> { .raw() ); - let ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก:anon: on 3183e43 -โ”‚ โ””โ”€โ”€ :anon: -โ”‚ โ””โ”€โ”€ ยทd6bdeab (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:outside on 3183e43 {1} - โ””โ”€โ”€ ๐Ÿ“™:outside - โ”œโ”€โ”€ ยท5121eb9* - โ””โ”€โ”€ ยท67c6397 (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:outside on 3183e43 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:outside +โ”‚ โ”œโ”€โ”€ ยท5121eb9* +โ”‚ โ””โ”€โ”€ ยท67c6397 (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก:anon: on 3183e43 + โ””โ”€โ”€ :anon: + โ””โ”€โ”€ ยทd6bdeab (๐Ÿ˜๏ธ) "#]] ); @@ -1243,13 +1230,13 @@ Outcome { graph_workspace(&out.workspace).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก:anon: on 3183e43 -โ”‚ โ””โ”€โ”€ :anon: -โ”‚ โ””โ”€โ”€ ยทd6bdeab (๐Ÿ˜๏ธ) โ”œโ”€โ”€ โ‰ก๐Ÿ“™:outside on 3183e43 {1} โ”‚ โ””โ”€โ”€ ๐Ÿ“™:outside โ”‚ โ”œโ”€โ”€ ยท5121eb9 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ยท67c6397 (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก:anon: on 3183e43 +โ”‚ โ””โ”€โ”€ :anon: +โ”‚ โ””โ”€โ”€ ยทd6bdeab (๐Ÿ˜๏ธ) โ””โ”€โ”€ โ‰ก๐Ÿ“™:feature on 3183e43 {2ec} โ””โ”€โ”€ ๐Ÿ“™:feature โ””โ”€โ”€ ยทd03b217 (๐Ÿ˜๏ธ) @@ -1261,7 +1248,7 @@ Outcome { #[test] fn ws_ref_no_ws_commit_two_stacks_on_same_commit() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-no-ws-commit-one-stack-one-branch", |_meta| {}, @@ -1273,7 +1260,6 @@ fn ws_ref_no_ws_commit_two_stacks_on_same_commit() -> anyhow::Result<()> { "#]] ); - let ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1398,25 +1384,24 @@ Outcome { #[test] fn unapply_natural_stack_with_partial_workspace_metadata() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-ws-commit-two-stacks", |meta| { add_stack_with_segments(meta, 1, "B", StackState::InWorkspace, &["not-in-graph"]); }, )?; - let ws = graph.into_workspace()?; // A is naturally visible, and B has metadata that matches it with an extra segment in metadata that's not there snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B -โ”‚ โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก:A on 85efbe4 - โ””โ”€โ”€ :A - โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก:A on 85efbe4 +โ”‚ โ””โ”€โ”€ :A +โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:B + โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] ); @@ -1444,7 +1429,7 @@ fn unapply_natural_stack_with_partial_workspace_metadata() -> anyhow::Result<()> let ws_md = out.workspace.metadata.as_ref().expect("present"); // the extra segment is still present in metadata to help with stack identities snapbox::assert_data_eq!( - ws_md.to_debug(), + &ws_md.to_debug(), snapbox::str![[r#" Workspace { ref_info: RefInfo { created_at: "2023-01-31 14:55:57 +0000", updated_at: None }, @@ -1476,25 +1461,24 @@ Workspace { #[test] fn unapply_natural_stack_branch_without_workspace_metadata() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-ws-commit-single-stack-double-stack-files", |_meta| {}, )?; - let ws = graph.into_workspace()?; // a single and multi-branch stack are visible without any workspace metadata snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 893d602 -โ”œโ”€โ”€ โ‰ก:C on 893d602 -โ”‚ โ”œโ”€โ”€ :C -โ”‚ โ”‚ โ””โ”€โ”€ ยท356de85 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ :B -โ”‚ โ””โ”€โ”€ ยทf25f65c (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก:A on 893d602 - โ””โ”€โ”€ :A - โ””โ”€โ”€ ยท26e45af (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก:A on 893d602 +โ”‚ โ””โ”€โ”€ :A +โ”‚ โ””โ”€โ”€ ยท26e45af (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก:C on 893d602 + โ”œโ”€โ”€ :C + โ”‚ โ””โ”€โ”€ ยท356de85 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :B + โ””โ”€โ”€ ยทf25f65c (๐Ÿ˜๏ธ) "#]] ); @@ -1543,25 +1527,25 @@ Workspace { id: 1, branches: [ WorkspaceStackBranch { - ref_name: "refs/heads/C", - archived: false, - }, - WorkspaceStackBranch { - ref_name: "refs/heads/B", + ref_name: "refs/heads/A", archived: false, }, ], - workspacecommit_relation: Outside, + workspacecommit_relation: Merged, }, WorkspaceStack { id: 2, branches: [ WorkspaceStackBranch { - ref_name: "refs/heads/A", + ref_name: "refs/heads/C", + archived: false, + }, + WorkspaceStackBranch { + ref_name: "refs/heads/B", archived: false, }, ], - workspacecommit_relation: Merged, + workspacecommit_relation: Outside, }, ], target_ref: "refs/remotes/origin/main", @@ -1596,7 +1580,7 @@ fn no_ws_ref_no_ws_commit_two_stacks_on_same_commit_ad_hoc_workspace_without_tar "we just deleted it, it should be transferred" ); } - let graph = but_graph::Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta), @@ -1609,7 +1593,6 @@ fn no_ws_ref_no_ws_commit_two_stacks_on_same_commit_ad_hoc_workspace_without_tar "#]] ); - let ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1713,10 +1696,7 @@ Outcome { } meta.set_workspace(&ws_md)?; - let ws = ws - .graph - .redo_traversal_with_overlay(&repo, &meta, Overlay::default())? - .into_workspace()?; + let ws = ws.redo(&repo, &meta, Overlay::default())?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1787,11 +1767,13 @@ Outcome { snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on e5d0542 {42} +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B {42} โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B -โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on e5d0542 {41} +โ”‚ โ””โ”€โ”€ ยทe5d0542 (๐Ÿ˜๏ธ) โ–บmain +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A {41} โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยทe5d0542 (๐Ÿ˜๏ธ) โ–บmain "#]] ); @@ -1808,7 +1790,7 @@ fn no_ws_ref_no_ws_commit_two_stacks_on_same_commit_ad_hoc_workspace_with_target |_meta| {}, )?; - let graph = but_graph::Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta), @@ -1821,7 +1803,6 @@ fn no_ws_ref_no_ws_commit_two_stacks_on_same_commit_ad_hoc_workspace_with_target "#]] ); - let ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1989,13 +1970,12 @@ fn apply_after_switching_out_of_workspace_drops_stale_stacks() -> anyhow::Result git(&repo).args(["branch", "applied", "feature"]).run(); git(&repo).args(["checkout", "feature"]).run(); - let ws = Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &meta, but_core::ref_metadata::ProjectMeta::default(), standard_traversal_options(), - )? - .into_workspace()?; + )?; // Apply a different branch from the adhoc `feature` checkout. The new workspace should hold the // current branch plus the applied one, while the stale `outside` stack is demoted. @@ -2051,10 +2031,10 @@ fn apply_from_enclosed_adhoc_workspace_rebuilds_around_current_and_applied() -> snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* 4fce3a1 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 0a752d2 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -| * ccf539c (A) A -* | 53c254d (B) B +| * 53c254d (B) B +* | ccf539c (A) A |/ * 893d602 (origin/main, main) M @@ -2086,12 +2066,12 @@ fn apply_from_enclosed_adhoc_workspace_rebuilds_around_current_and_applied() -> visualize_commit_graph_all(&repo)?, snapbox::str![[r#" * 863775d (C) add C -| * 4fce3a1 (gitbutler/workspace) GitButler Workspace Commit +| * 0a752d2 (gitbutler/workspace) GitButler Workspace Commit | |\ -| | * ccf539c (A) A +| | * 53c254d (HEAD -> B) B | |/ |/| -| * 53c254d (HEAD -> B) B +| * ccf539c (A) A |/ * 893d602 (origin/main, main) M @@ -2112,13 +2092,12 @@ fn apply_from_enclosed_adhoc_workspace_rebuilds_around_current_and_applied() -> "#]] ); - let ws = Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta), standard_traversal_options(), - )? - .into_workspace()?; + )?; assert!( matches!(ws.kind, WorkspaceKind::Managed { .. }), "direct checkout of B can still project as a managed workspace" @@ -2221,10 +2200,10 @@ fn apply_from_adhoc_checkout_rebuilds_around_current_and_applied() -> anyhow::Re snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* 4fce3a1 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 0a752d2 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -| * ccf539c (A) A -* | 53c254d (B) B +| * 53c254d (B) B +* | ccf539c (A) A |/ * 893d602 (origin/main, main) M @@ -2255,12 +2234,12 @@ fn apply_from_adhoc_checkout_rebuilds_around_current_and_applied() -> anyhow::Re visualize_commit_graph_all(&repo)?, snapbox::str![[r#" * 863775d (HEAD -> C) add C -| * 4fce3a1 (gitbutler/workspace) GitButler Workspace Commit +| * 0a752d2 (gitbutler/workspace) GitButler Workspace Commit | |\ -| | * ccf539c (A) A +| | * 53c254d (B) B | |/ |/| -| * 53c254d (B) B +| * ccf539c (A) A |/ * 893d602 (origin/main, main) M @@ -2281,13 +2260,12 @@ fn apply_from_adhoc_checkout_rebuilds_around_current_and_applied() -> anyhow::Re "#]] ); - let ws = Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta), standard_traversal_options(), - )? - .into_workspace()?; + )?; // direct checkout of C is an ad-hoc workspace next to the existing managed workspace snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -2384,13 +2362,12 @@ fn apply_already_applied_branch_from_adhoc_checkout_excludes_other_applied_stack git(&repo).args(["checkout", "A"]).run(); assert_worktree_files(&repo, &["A"], &["B", "C"]); - let ws = Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta), standard_traversal_options(), - )? - .into_workspace()?; + )?; let out = but_workspace::branch::apply(r("refs/heads/C"), ws, &repo, &mut meta, apply_options())?; @@ -2423,7 +2400,7 @@ fn apply_already_applied_branch_from_adhoc_checkout_excludes_other_applied_stack #[test] fn new_workspace_exists_elsewhere_and_to_be_applied_branch_exists_there() -> anyhow::Result<()> { - let (_tmp, ws_graph, repo, mut meta, _description) = + let (_tmp, initial_ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-no-ws-commit-one-stack-one-branch", |_meta| {}, @@ -2437,7 +2414,7 @@ fn new_workspace_exists_elsewhere_and_to_be_applied_branch_exists_there() -> any ); // The default workspace, it's empty as target is set to `main`. snapbox::assert_data_eq!( - graph_workspace(&ws_graph.into_workspace()?).to_string(), + graph_workspace(&initial_ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 @@ -2446,14 +2423,13 @@ fn new_workspace_exists_elsewhere_and_to_be_applied_branch_exists_there() -> any // Pretend "B" is checked out (it's at the right state independently of that) let (b_id, b_ref) = id_at(&repo, "B"); - let graph = but_graph::Graph::from_commit_traversal( + let ws = but_graph::Workspace::from_tip( b_id, b_ref, &meta, but_core::ref_metadata::ProjectMeta::default(), - but_graph::init::Options::default(), + but_graph::walk::Options::default(), )?; - let ws = graph.into_workspace()?; // Note how the existing `gitbutler/workspace` disappears, which is expected here. snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -2530,28 +2506,28 @@ mod unapply_checked_out { }, )?; let initial_graph = visualize_commit_graph_all(&repo)?; - - snapbox::assert_data_eq!( - initial_graph, - snapbox::str![[r#" + { + snapbox::assert_data_eq!( + initial_graph, + snapbox::str![[r#" * e5d0542 (HEAD -> gitbutler/workspace, main, B, A) A "#]] - ); + ); + } git(&repo).args(["checkout", "B"]).run(); - let ws = Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &meta, but_core::ref_metadata::ProjectMeta::default(), standard_traversal_options(), - )? - .into_workspace()?; - - // B is checked out directly, and its stack is virtual - snapbox::assert_data_eq!( - graph_workspace(&ws).to_string(), - snapbox::str![[r#" + )?; + { + // B is checked out directly, and its stack is virtual + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“! on e5d0542 โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on e5d0542 {1} โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A @@ -2559,23 +2535,24 @@ mod unapply_checked_out { โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:B[๐ŸŒณ] "#]] - ); + ); + } Ok((tmp, repo, meta, ws)) } fn real_stack_tip_checked_out() -> anyhow::Result { - let (tmp, graph, repo, mut meta, _description) = + let (tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "detached-with-multiple-branches", |_meta| {}, )?; let initial_graph = visualize_commit_graph_all(&repo)?; - - // the initial checkout is detached, so the workspace has no target ref to fall back to - snapbox::assert_data_eq!( - initial_graph, - snapbox::str![[r#" + { + // the initial checkout is detached, so the workspace has no target ref to fall back to + snapbox::assert_data_eq!( + initial_graph, + snapbox::str![[r#" * 49d4b34 (A) A1 | * f57c528 (B) B1 |/ @@ -2584,9 +2561,8 @@ mod unapply_checked_out { * 3183e43 (main) M1 "#]] - ); - - let mut ws = graph.into_workspace()?; + ); + } for branch_to_apply in ["C", "B"] { let out = but_workspace::branch::apply( Category::LocalBranch @@ -2599,11 +2575,11 @@ mod unapply_checked_out { )?; ws = out.workspace; } - - // C and B are real stacks in the managed workspace - snapbox::assert_data_eq!( - graph_workspace(&ws).to_string(), - snapbox::str![[r#" + { + // C and B are real stacks in the managed workspace + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on 3183e43 โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 3183e43 {43} โ”‚ โ””โ”€โ”€ ๐Ÿ“™:C @@ -2613,21 +2589,21 @@ mod unapply_checked_out { โ””โ”€โ”€ ยทf57c528 (๐Ÿ˜๏ธ) "#]] - ); + ); + } git(&repo).args(["checkout", "B"]).run(); - let ws = Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &meta, but_core::ref_metadata::ProjectMeta::default(), standard_traversal_options(), - )? - .into_workspace()?; - - // B is checked out directly, and its stack has a real commit - snapbox::assert_data_eq!( - graph_workspace(&ws).to_string(), - snapbox::str![[r#" + )?; + { + // B is checked out directly, and its stack has a real commit + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace <> โœ“! on 3183e43 โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 3183e43 {43} โ”‚ โ””โ”€โ”€ ๐Ÿ“™:C @@ -2637,7 +2613,8 @@ mod unapply_checked_out { โ””โ”€โ”€ ยทf57c528 (๐Ÿ˜๏ธ) "#]] - ); + ); + } Ok((tmp, repo, meta, ws)) } @@ -2747,13 +2724,12 @@ Outcome { )?; git(&repo).args(["checkout", "A"]).run(); - let ws = Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta), standard_traversal_options(), - )? - .into_workspace()?; + )?; // A is checked out as a lower segment of the B stack snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -2959,7 +2935,7 @@ Outcome { #[test] fn apply_multiple_without_target_or_metadata_or_base() -> anyhow::Result<()> { - let (_tmp, mut graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph("one-fork", |meta| { meta.data_mut().default_target = None; })?; @@ -2977,9 +2953,8 @@ fn apply_multiple_without_target_or_metadata_or_base() -> anyhow::Result<()> { "#]] ); - graph.options.extra_target_commit_id = None; - let graph = graph.redo_traversal_with_overlay(&repo, &meta, Overlay::default())?; - let ws = graph.into_workspace()?; + ws.options_mut().extra_target_commit_id = None; + let ws = ws.redo(&repo, &meta, Overlay::default())?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -3184,7 +3159,7 @@ Outcome { #[test] fn unapply_dirty_worktree_abort_keeps_refs_and_metadata() -> anyhow::Result<()> { - let (_tmp, mut graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph("one-fork", |meta| { meta.data_mut().default_target = None; })?; @@ -3201,9 +3176,8 @@ fn unapply_dirty_worktree_abort_keeps_refs_and_metadata() -> anyhow::Result<()> "#]] ); - graph.options.extra_target_commit_id = None; - let graph = graph.redo_traversal_with_overlay(&repo, &meta, Overlay::default())?; - let ws = graph.into_workspace()?; + ws.options_mut().extra_target_commit_id = None; + let ws = ws.redo(&repo, &meta, Overlay::default())?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -3284,13 +3258,12 @@ Context { refs_before, "refs must not move when dirty worktree checkout aborts" ); - let ws_after = but_graph::Graph::from_head( + let ws_after = but_graph::Workspace::from_head( &repo, &meta, but_core::ref_metadata::ProjectMeta::default(), standard_traversal_options(), - )? - .into_workspace()?; + )?; assert_eq!(graph_workspace(&ws_after).to_string(), ws_before); assert_eq!( sanitize_uuids_and_timestamps(format!( @@ -3307,11 +3280,10 @@ Context { #[test] fn apply_repairs_stale_outside_metadata_for_reachable_branch() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph("ws-ref-ws-commit-one-stack", |meta| { add_stack_with_segments(meta, 1, "B", StackState::InWorkspace, &["A"]); })?; - let ws = graph.into_workspace()?; assert!( ws.is_reachable_from_entrypoint(r("refs/heads/B")), "fixture must start with B visible in the cached workspace graph" @@ -3345,7 +3317,7 @@ fn apply_repairs_stale_outside_metadata_for_reachable_branch() -> anyhow::Result #[test] fn apply_multiple_segments_of_stack_in_order_merge_if_needed() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "single-stack-two-segments", |_meta| {}, @@ -3362,7 +3334,6 @@ fn apply_multiple_segments_of_stack_in_order_merge_if_needed() -> anyhow::Result "#]] ); - let ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -3620,7 +3591,6 @@ Outcome { "#]] ); - // snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" @@ -3637,7 +3607,7 @@ Outcome { #[test] fn unapply_existing_branch_outside_detached_ad_hoc_workspace_is_noop() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "detached-with-multiple-branches", |_meta| {}, @@ -3654,9 +3624,6 @@ fn unapply_existing_branch_outside_detached_ad_hoc_workspace_is_noop() -> anyhow "#]] ); - let ws = graph - .into_workspace() - .expect("detached graph is a workspace"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -3709,14 +3676,13 @@ fn unapply_branch_from_detached_ad_hoc_workspace_is_an_error() -> anyhow::Result ); let a2_id = repo.rev_parse_single("A2")?.detach(); - let ws = Graph::from_commit_traversal_tips( + let ws = but_graph::Workspace::from_seeds( &repo, - [Tip::detached_entrypoint(a2_id)], + [Seed::detached_entrypoint(a2_id)], &meta, but_core::ref_metadata::ProjectMeta::default(), standard_traversal_options(), - )? - .into_workspace()?; + )?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -3745,7 +3711,7 @@ fn unapply_branch_from_detached_ad_hoc_workspace_is_an_error() -> anyhow::Result #[test] fn detached_head_journey() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "detached-with-multiple-branches", |_meta| {}, @@ -3762,7 +3728,6 @@ fn detached_head_journey() -> anyhow::Result<()> { "#]] ); - let ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -4014,7 +3979,7 @@ Outcome { #[test] fn unapply_workspace_ref_without_target_checks_out_named_stack() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "detached-with-multiple-branches", |_meta| {}, @@ -4032,7 +3997,6 @@ fn unapply_workspace_ref_without_target_checks_out_named_stack() -> anyhow::Resu "#]] ); - let mut ws = graph.into_workspace()?; for branch_to_apply in ["C", "B"] { let out = but_workspace::branch::apply( @@ -4171,13 +4135,12 @@ fn unapply_workspace_ref_refuses_conflicted_named_stack_checkout() -> anyhow::Re .run(); git(&repo).args(["reset", "--hard", "normal"]).run(); - let ws = Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta), standard_traversal_options(), - )? - .into_workspace()?; + )?; let out = but_workspace::branch::apply( r("refs/heads/tip-conflicted"), ws, @@ -4227,7 +4190,7 @@ fn unapply_workspace_ref_refuses_conflicted_named_stack_checkout() -> anyhow::Re #[test] fn apply_two_ambiguous_stacks_with_target_with_dependent_branch() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "no-ws-ref-stack-and-dependent-branch", |meta| { @@ -4245,7 +4208,6 @@ fn apply_two_ambiguous_stacks_with_target_with_dependent_branch() -> anyhow::Res "#]] ); - let ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -4295,7 +4257,7 @@ Outcome { โ”œโ”€โ”€ โ‰ก๐Ÿ“™:E on 85efbe4 {1} โ”‚ โ””โ”€โ”€ ๐Ÿ“™:E โ”‚ โ””โ”€โ”€ ยท7076dee (๐Ÿ˜๏ธ) โ–บD -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 7076dee {43} +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C {43} โ””โ”€โ”€ ๐Ÿ“™:C โ””โ”€โ”€ ยทf084d61 (๐Ÿ˜๏ธ) โ–บA, โ–บB @@ -4330,7 +4292,7 @@ Outcome { โ”œโ”€โ”€ โ‰ก๐Ÿ“™:E on 85efbe4 {1} โ”‚ โ””โ”€โ”€ ๐Ÿ“™:E โ”‚ โ””โ”€โ”€ ยท7076dee (๐Ÿ˜๏ธ) โ–บD -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 7076dee {2} +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B {2} โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทf084d61 (๐Ÿ˜๏ธ) โ–บA, โ–บC @@ -4351,7 +4313,7 @@ Outcome { โ”œโ”€โ”€ โ‰ก๐Ÿ“™:E on 85efbe4 {1} โ”‚ โ””โ”€โ”€ ๐Ÿ“™:E โ”‚ โ””โ”€โ”€ ยท7076dee (๐Ÿ˜๏ธ) โ–บD -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 7076dee {2} +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C {2} โ”œโ”€โ”€ ๐Ÿ“™:C โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทf084d61 (๐Ÿ˜๏ธ) โ–บA @@ -4364,7 +4326,7 @@ Outcome { #[test] fn apply_two_ambiguous_stacks_with_target() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "no-ws-ref-stack-and-dependent-branch", |_meta| {}, @@ -4379,7 +4341,6 @@ fn apply_two_ambiguous_stacks_with_target() -> anyhow::Result<()> { "#]] ); - let ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -4918,7 +4879,7 @@ fn apply_with_conflicts_shows_exact_conflict_info() -> anyhow::Result<()> { // Replaying that graph would correctly keep using `conflict-hero` as the traversal // entrypoint, even though the test just checked out `main`. Build the graph from // the current repository state so the workspace under test starts at `main`. - let mut ws = but_graph::Graph::from_head( + let mut ws = but_graph::Workspace::from_head( &repo, &meta, but_core::ref_metadata::ProjectMeta::default(), @@ -4926,8 +4887,7 @@ fn apply_with_conflicts_shows_exact_conflict_info() -> anyhow::Result<()> { extra_target_commit_id: repo.rev_parse_single("main").ok().map(|id| id.detach()), ..Options::limited() }, - )? - .into_workspace()?; + )?; for branch_to_apply in [ "clean-A", @@ -5241,7 +5201,7 @@ Workspace { #[test] fn conflicting_apply_reports_no_applied_branches_and_names_conflicting_stacks() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "one-fork-with-conflicting-sibling", |_meta| {}, @@ -5261,7 +5221,6 @@ fn conflicting_apply_reports_no_applied_branches_and_names_conflicting_stacks() "#]] ); - let ws = graph.into_workspace()?; let out = but_workspace::branch::apply(r("refs/heads/A"), ws, &repo, &mut meta, apply_options())?; let ws = out.workspace; @@ -5278,7 +5237,7 @@ fn conflicting_apply_reports_no_applied_branches_and_names_conflicting_stacks() ); let refs_before = visualize_commit_graph_all(&repo)?; snapbox::assert_data_eq!( - &refs_before, + refs_before.as_str(), snapbox::str![[r#" * 543911c (add-A-too) add a different A * b1540e5 (main) M @@ -5334,7 +5293,7 @@ Outcome { #[test] fn unapply_with_workspace_merge_conflicts_always_works_as_conflicts_do_not_repeat_on_unapply() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "various-heads-for-multi-line-merge-conflict-on-main", |_meta| {}, @@ -5359,7 +5318,6 @@ fn unapply_with_workspace_merge_conflicts_always_works_as_conflicts_do_not_repea "#]] ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -5446,7 +5404,7 @@ fn unapply_with_workspace_merge_conflicts_always_works_as_conflicts_do_not_repea #[test] fn auto_checkout_of_enclosing_workspace_flat() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-no-ws-commit-one-stack-one-branch", |meta| { @@ -5463,7 +5421,6 @@ fn auto_checkout_of_enclosing_workspace_flat() -> anyhow::Result<()> { "#]] ); - let ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -5498,14 +5455,13 @@ Outcome { ); let (b_id, b_ref) = id_at(&repo, "B"); - let ws = but_graph::Graph::from_commit_traversal( + let ws = but_graph::Workspace::from_tip( b_id, b_ref.clone(), &meta, but_core::ref_metadata::ProjectMeta::default(), standard_traversal_options_with_extra_target(&repo), - )? - .into_workspace()?; + )?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -5601,14 +5557,13 @@ Outcome { let (b_id, b_ref) = id_at(&repo, "B"); - let ws = but_graph::Graph::from_commit_traversal( + let ws = but_graph::Workspace::from_tip( b_id, b_ref.clone(), &meta, but_core::ref_metadata::ProjectMeta::default(), standard_traversal_options_with_extra_target(&repo), - )? - .into_workspace()?; + )?; // V-branch B is checked out snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -5638,13 +5593,12 @@ Outcome { // There is no known branch, and adding it will just add metadata. meta.data_mut().branches.clear(); - let ws = but_graph::Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &meta, but_core::ref_metadata::ProjectMeta::default(), standard_traversal_options_with_extra_target(&repo), - )? - .into_workspace()?; + )?; // There is nothing yet. // metadata defines no branches snapbox::assert_data_eq!( @@ -5713,14 +5667,13 @@ Outcome { ); let (b_id, b_ref) = id_at(&repo, "B"); - let ws = but_graph::Graph::from_commit_traversal( + let ws = but_graph::Workspace::from_tip( b_id, b_ref.clone(), &meta, but_core::ref_metadata::ProjectMeta::default(), standard_traversal_options_with_extra_target(&repo), - )? - .into_workspace()?; + )?; // the same result when checked out directly snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -5810,7 +5763,7 @@ Outcome { #[test] fn auto_checkout_of_enclosing_workspace_with_commits() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-ws-commit-two-stacks", |meta| { @@ -5822,10 +5775,10 @@ fn auto_checkout_of_enclosing_workspace_with_commits() -> anyhow::Result<()> { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* c49e4d8 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 3147679 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -| * 09d8e52 (A) A -* | c813d8d (B) B +| * c813d8d (B) B +* | 09d8e52 (A) A |/ * 85efbe4 (origin/main, main) M @@ -5833,7 +5786,6 @@ fn auto_checkout_of_enclosing_workspace_with_commits() -> anyhow::Result<()> { .raw() ); - let ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -5865,14 +5817,13 @@ Outcome { ); let (b_id, b_ref) = id_at(&repo, "B"); - let ws = but_graph::Graph::from_commit_traversal( + let ws = but_graph::Workspace::from_tip( b_id, b_ref.clone(), &meta, project_meta(&meta), - but_graph::init::Options::default(), - )? - .into_workspace()?; + but_graph::walk::Options::default(), + )?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -5977,13 +5928,12 @@ fn apply_nonexisting_branch_failure() -> anyhow::Result<()> { .as_mut() .expect("workspace configured") .sha = gix::hash::Kind::Sha1.null(); - let graph = but_graph::Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), Options::limited(), )?; - let ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -6036,13 +5986,12 @@ fn unapply_nonexisting_branch() -> anyhow::Result<()> { .as_mut() .expect("workspace configured") .sha = gix::hash::Kind::Sha1.null(); - let graph = but_graph::Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), Options::limited(), )?; - let ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -6084,15 +6033,14 @@ fn unborn_apply_needs_base() -> anyhow::Result<()> { named_read_only_in_memory_scenario("unborn-empty-detached-remote", "unborn")?; // Depending on the Git version it produces`* 3183e43 (orphan/main, orphan/HEAD) M1` on CI, // so a comment is used as reference. - // snapbox::assert_data_eq!(visualize_commit_graph_all(&repo)?, snapbox::str!["* 3183e43 (orphan/main) M1"]); + // insta::assert_snapshot!(visualize_commit_graph_all(&repo)?, @"* 3183e43 (orphan/main) M1"); - let graph = but_graph::Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), Options::limited(), )?; - let ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -6196,21 +6144,20 @@ fn stack_id_for_name(rn: &gix::refs::FullNameRef) -> StackId { } mod utils { - pub fn standard_traversal_options() -> but_graph::init::Options { - but_graph::init::Options { + pub fn standard_traversal_options() -> but_graph::walk::Options { + but_graph::walk::Options { collect_tags: true, commits_limit_hint: None, commits_limit_recharge_location: vec![], hard_limit: None, extra_target_commit_id: None, - dangerously_skip_postprocessing_for_debugging: false, } } pub fn standard_traversal_options_with_extra_target( repo: &gix::Repository, - ) -> but_graph::init::Options { - but_graph::init::Options { + ) -> but_graph::walk::Options { + but_graph::walk::Options { extra_target_commit_id: Some(repo.rev_parse_single("main").expect("present").detach()), ..standard_traversal_options() } diff --git a/crates/but-workspace/tests/workspace/branch/create_reference.rs b/crates/but-workspace/tests/workspace/branch/create_reference.rs index 7359cb42494..1f8a9bac82d 100644 --- a/crates/but-workspace/tests/workspace/branch/create_reference.rs +++ b/crates/but-workspace/tests/workspace/branch/create_reference.rs @@ -3,7 +3,7 @@ use but_core::{ RefMetadata, ref_metadata::{StackId, ValueInfo}, }; -use but_graph::init::Options; +use but_graph::walk::Options; use but_meta::BranchOrderMetadata; use but_testsupport::{graph_workspace, id_at, id_by_rev, visualize_commit_graph_all}; use but_workspace::branch::create_reference::{Anchor, Position::*}; @@ -32,11 +32,11 @@ fn branch_order_meta(repo: &gix::Repository) -> anyhow::Result โœ“refs/remotes/origin/main on bce0c5e -โ””โ”€โ”€ โ‰ก๐Ÿ“™:above-A-commit on bce0c5e {4cf} - โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit +โ””โ”€โ”€ โ‰ก๐Ÿ“™:above-A on bce0c5e {4cf} โ”œโ”€โ”€ ๐Ÿ“™:above-A โ”œโ”€โ”€ ๐Ÿ“™:A + โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit โ”‚ โ””โ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ๐Ÿ“™:below-A โ”œโ”€โ”€ ๐Ÿ“™:below-A-commit โ”œโ”€โ”€ ๐Ÿ“™:above-bottom + โ”œโ”€โ”€ ๐Ÿ“™:below-A โ”‚ โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) โ””โ”€โ”€ ๐Ÿ“™:bottom @@ -589,14 +584,14 @@ Single commit, target, no ws commit, but ws-reference graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:above-A-commit on bce0c5e {4cf} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:above-A on bce0c5e {4cf} โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit โ”‚ โ”‚ โ””โ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A-commit โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-bottom +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A โ”‚ โ”‚ โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ๐Ÿ“™:bottom โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on bce0c5e {42} @@ -623,14 +618,14 @@ Single commit, target, no ws commit, but ws-reference graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:above-A-commit on bce0c5e {4cf} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:above-A on bce0c5e {4cf} โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit โ”‚ โ”‚ โ””โ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A-commit โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-bottom +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A โ”‚ โ”‚ โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ๐Ÿ“™:bottom โ””โ”€โ”€ โ‰ก๐Ÿ“™:above-B on bce0c5e {42} @@ -660,14 +655,14 @@ Single commit, target, no ws commit, but ws-reference graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:above-A-commit on bce0c5e {4cf} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:above-A on bce0c5e {4cf} โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit โ”‚ โ”‚ โ””โ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A-commit โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-bottom +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A โ”‚ โ”‚ โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ๐Ÿ“™:bottom โ””โ”€โ”€ โ‰ก๐Ÿ“™:above-B on bce0c5e {42} @@ -682,21 +677,20 @@ Single commit, target, no ws commit, but ws-reference let path = meta.path().to_owned(); drop(meta); let meta = VirtualBranchesTomlMetadata::from_path(path)?; - let graph = - but_graph::Graph::from_head(&repo, &meta, project_meta(&meta), Options::limited())?; - let ws = graph.into_workspace()?; + let ws = + but_graph::Workspace::from_head(&repo, &meta, project_meta(&meta), Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:above-A-commit on bce0c5e {4cf} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:above-A on bce0c5e {4cf} โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit โ”‚ โ”‚ โ””โ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A-commit โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-bottom +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A โ”‚ โ”‚ โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ๐Ÿ“™:bottom โ””โ”€โ”€ โ‰ก๐Ÿ“™:above-B on bce0c5e {42} @@ -737,9 +731,8 @@ Single commit, target, no ws commit, but ws-reference add_stack_with_segments(&mut meta, 0, "A", StackState::InWorkspace, &[]); - let graph = - but_graph::Graph::from_head(&repo, &meta, project_meta(&meta), Options::limited())?; - let ws = graph.into_workspace()?; + let ws = + but_graph::Workspace::from_head(&repo, &meta, project_meta(&meta), Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -1075,9 +1068,8 @@ Single commit, target, no ws commit, but ws-reference let path = meta.path().to_owned(); drop(meta); let meta = VirtualBranchesTomlMetadata::from_path(path)?; - let graph = - but_graph::Graph::from_head(&repo, &meta, project_meta(&meta), Options::limited())?; - let ws = graph.into_workspace()?; + let ws = + but_graph::Workspace::from_head(&repo, &meta, project_meta(&meta), Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1128,9 +1120,8 @@ Single commit, target, no ws commit, but ws-reference add_stack_with_segments(&mut meta, 0, "A", StackState::InWorkspace, &[]); - let graph = - but_graph::Graph::from_head(&repo, &meta, project_meta(&meta), Options::limited())?; - let ws = graph.into_workspace()?; + let ws = + but_graph::Workspace::from_head(&repo, &meta, project_meta(&meta), Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -1194,20 +1185,19 @@ Single commit, target, no ws commit, but ws-reference add_stack_with_segments(&mut meta, 0, "A", StackState::InWorkspace, &[]); add_stack_with_segments(&mut meta, 1, "B", StackState::InWorkspace, &[]); - let graph = - but_graph::Graph::from_head(&repo, &meta, project_meta(&meta), Options::limited())?; - let ws = graph.into_workspace()?; + let ws = + but_graph::Workspace::from_head(&repo, &meta, project_meta(&meta), Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {1} - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยทf57c528 (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยทf57c528 (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {0} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) "#]] ); @@ -1230,13 +1220,13 @@ Single commit, target, no ws commit, but ws-reference graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A -โ”‚ โ”‚ โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:a-bottom -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {1} - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยทf57c528 (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยทf57c528 (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {0} + โ”œโ”€โ”€ ๐Ÿ“™:A + โ”‚ โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ๐Ÿ“™:a-bottom "#]] ); @@ -1260,14 +1250,14 @@ Single commit, target, no ws commit, but ws-reference graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A -โ”‚ โ”‚ โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:a-bottom -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {1} - โ”œโ”€โ”€ ๐Ÿ“™:B - โ”‚ โ””โ”€โ”€ ยทf57c528 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:b-bottom +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {1} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ”‚ โ””โ”€โ”€ ยทf57c528 (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:b-bottom +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {0} + โ”œโ”€โ”€ ๐Ÿ“™:A + โ”‚ โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ๐Ÿ“™:a-bottom "#]] ); @@ -1291,9 +1281,8 @@ Single commit, target, no ws commit, but ws-reference add_stack_with_segments(&mut meta, 0, "A", StackState::InWorkspace, &[]); - let graph = - but_graph::Graph::from_head(&repo, &meta, project_meta(&meta), Options::limited())?; - let ws = graph.into_workspace()?; + let ws = + but_graph::Workspace::from_head(&repo, &meta, project_meta(&meta), Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1456,9 +1445,8 @@ Single commit, target, no ws commit, but ws-reference let path = meta.path().to_owned(); drop(meta); let meta = VirtualBranchesTomlMetadata::from_path(path)?; - let graph = - but_graph::Graph::from_head(&repo, &meta, project_meta(&meta), Options::limited())?; - let ws = graph.into_workspace()?; + let ws = + but_graph::Workspace::from_head(&repo, &meta, project_meta(&meta), Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1500,9 +1488,8 @@ Single commit, target, no ws commit, but ws-reference "#]] ); - let graph = - but_graph::Graph::from_head(&repo, &meta, project_meta(&meta), Options::limited())?; - let ws = graph.into_workspace()?; + let ws = + but_graph::Workspace::from_head(&repo, &meta, project_meta(&meta), Options::limited())?; let a_ref = r("refs/heads/A"); let ws = but_workspace::branch::create_reference( @@ -1596,9 +1583,8 @@ Single commit, target, no ws commit, but ws-reference .sha = gix::hash::Kind::Sha1.null(); add_stack_with_segments(&mut meta, 0, "main", StackState::InWorkspace, &[]); - let graph = - but_graph::Graph::from_head(&repo, &meta, project_meta(&meta), Options::limited())?; - let ws = graph.into_workspace()?; + let ws = + but_graph::Workspace::from_head(&repo, &meta, project_meta(&meta), Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1666,19 +1652,18 @@ Single commit, target, no ws commit, but ws-reference add_stack_with_segments(&mut meta, 0, "A", StackState::InWorkspace, &[]); add_stack_with_segments(&mut meta, 1, "B", StackState::InWorkspace, &[]); - let graph = - but_graph::Graph::from_head(&repo, &meta, project_meta(&meta), Options::limited())?; - let ws = graph.into_workspace()?; + let ws = + but_graph::Workspace::from_head(&repo, &meta, project_meta(&meta), Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {1} - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยทf57c528 (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยทf57c528 (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {0} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) "#]] ); @@ -1702,13 +1687,13 @@ Single commit, target, no ws commit, but ws-reference graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {1} - โ”œโ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ๐Ÿ“™:new - โ””โ”€โ”€ ยทf57c528 (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {1} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:new +โ”‚ โ””โ”€โ”€ ยทf57c528 (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {0} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) "#]] ); @@ -1718,9 +1703,8 @@ Single commit, target, no ws commit, but ws-reference #[test] fn at_reference_errors() -> anyhow::Result<()> { let (_tmp, repo, mut meta) = named_writable_scenario("single-branch-4-commits")?; - let graph = - but_graph::Graph::from_head(&repo, &meta, project_meta(&meta), Options::limited())?; - let ws = graph.into_workspace()?; + let ws = + but_graph::Workspace::from_head(&repo, &meta, project_meta(&meta), Options::limited())?; // The anchor must be a segment within the workspace. let new_ref = r("refs/heads/new"); @@ -1805,14 +1789,21 @@ Single commit, target, no ws commit, but ws-reference "#]] ); - let graph = - but_graph::Graph::from_head(&repo, &*meta, project_meta(&*meta), Options::limited())?; - let ws = graph.into_workspace()?; + let ws = but_graph::Workspace::from_head( + &repo, + &*meta, + project_meta(&*meta), + Options::limited(), + )?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on bce0c5e +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 +โ””โ”€โ”€ โ‰ก:main <> origin/mainโ‡ก1โ‡ฃ1 + โ””โ”€โ”€ :main <> origin/mainโ‡ก1โ‡ฃ1 + โ”œโ”€โ”€ ๐ŸŸฃ3183e43 (โœ“) + โ””โ”€โ”€ โœ‚๏ธยทbce0c5e (๐Ÿ˜๏ธ) "#]] ); @@ -1876,9 +1867,12 @@ Single commit, target, no ws commit, but ws-reference add_stack_with_segments(&mut meta, 0, "A", StackState::InWorkspace, &[]); - let graph = - but_graph::Graph::from_head(&repo, &*meta, project_meta(&*meta), Options::limited())?; - let ws = graph.into_workspace()?; + let ws = but_graph::Workspace::from_head( + &repo, + &*meta, + project_meta(&*meta), + Options::limited(), + )?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -2033,9 +2027,8 @@ Single commit, target, no ws commit, but ws-reference // `A` is applied (in the workspace), based at M1. add_stack_with_segments(&mut meta, 0, "A", StackState::InWorkspace, &[]); - let graph = - but_graph::Graph::from_head(&repo, &meta, project_meta(&meta), Options::limited())?; - let ws = graph.into_workspace()?; + let ws = + but_graph::Workspace::from_head(&repo, &meta, project_meta(&meta), Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -2099,13 +2092,12 @@ Single commit, target, no ws commit, but ws-reference #[test] fn errors() -> anyhow::Result<()> { let (repo, mut meta) = named_read_only_in_memory_scenario("unborn-empty", "")?; - let graph = but_graph::Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), Options::limited(), )?; - let ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -2145,9 +2137,8 @@ fn errors() -> anyhow::Result<()> { "#]] ); - let graph = - but_graph::Graph::from_head(&repo, &*meta, project_meta(&*meta), Options::limited())?; - let ws = graph.into_workspace()?; + let ws = + but_graph::Workspace::from_head(&repo, &*meta, project_meta(&*meta), Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -2254,14 +2245,13 @@ fn errors() -> anyhow::Result<()> { ); } - let graph = but_graph::Graph::from_commit_traversal( + let ws = but_graph::Workspace::from_tip( a_id, a_ref, &*meta, but_core::ref_metadata::ProjectMeta::default(), Options::limited(), )?; - let ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -2307,7 +2297,7 @@ fn errors() -> anyhow::Result<()> { ); } - let graph = but_graph::Graph::from_commit_traversal( + let ws = but_graph::Workspace::from_tip( a_id, a_ref.to_owned(), &*meta, @@ -2318,7 +2308,6 @@ fn errors() -> anyhow::Result<()> { ..Options::limited() }, )?; - let ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -2378,13 +2367,12 @@ fn journey_with_commits() -> anyhow::Result<()> { "#]] ); - let graph = but_graph::Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &meta, but_core::ref_metadata::ProjectMeta::default(), - but_graph::init::Options::default(), + but_graph::walk::Options::default(), )?; - let ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -2577,8 +2565,8 @@ fn journey_with_commits() -> anyhow::Result<()> { #[test] fn existing_git_ref_inside_workspace_is_adopted() -> anyhow::Result<()> { let (_tmp, repo, mut meta) = named_writable_scenario("single-branch-4-commits")?; - let graph = but_graph::Graph::from_head(&repo, &meta, project_meta(&meta), Options::limited())?; - let ws = graph.into_workspace()?; + let ws = + but_graph::Workspace::from_head(&repo, &meta, project_meta(&meta), Options::limited())?; let test_ref = r("refs/heads/created-with-git"); let target_id = id_by_rev(&repo, ":/A1").detach(); @@ -2640,14 +2628,13 @@ fn journey_anon_workspace() -> anyhow::Result<()> { ); let id = id_by_rev(&repo, "@~1"); - let graph = but_graph::Graph::from_commit_traversal( + let ws = but_graph::Workspace::from_tip( id, None, &meta, but_core::ref_metadata::ProjectMeta::default(), - but_graph::init::Options::default(), + but_graph::walk::Options::default(), )?; - let ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -2752,7 +2739,7 @@ fn journey_anon_workspace() -> anyhow::Result<()> { assert!(repo.try_find_reference(new)?.is_none()); // Give the graph a base - let graph = but_graph::Graph::from_commit_traversal( + let ws = but_graph::Workspace::from_tip( id, None, &meta, @@ -2762,7 +2749,6 @@ fn journey_anon_workspace() -> anyhow::Result<()> { ..Default::default() }, )?; - let ws = graph.into_workspace()?; // And the extra-target serves as base also in single-branch mode. snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -2800,9 +2786,12 @@ mod ad_hoc_at_reference { let (tmp, repo, legacy_meta) = named_writable_scenario("single-branch-with-3-commits")?; let project_meta = project_meta(&legacy_meta); let meta = branch_order_meta(&repo)?; - let ws = - but_graph::Graph::from_head(&repo, &meta, project_meta.clone(), Options::limited())? - .into_workspace()?; + let ws = but_graph::Workspace::from_head( + &repo, + &meta, + project_meta.clone(), + Options::limited(), + )?; Ok((tmp, repo, meta, project_meta, ws)) } @@ -2894,8 +2883,7 @@ mod ad_hoc_at_reference { // A TOML-only backend can't persist order, so ad-hoc `AtReference` is refused up front. let (_tmp, repo, mut meta) = named_writable_scenario("single-branch-with-3-commits")?; let ws = - but_graph::Graph::from_head(&repo, &meta, project_meta(&meta), Options::limited())? - .into_workspace()?; + but_graph::Workspace::from_head(&repo, &meta, project_meta(&meta), Options::limited())?; let new_ref = r("refs/heads/new"); let err = but_workspace::branch::create_reference( @@ -2927,8 +2915,7 @@ mod ad_hoc_at_reference { let main_ref = r("refs/heads/main"); create(&repo, &ws, &mut meta, bottom_ref, main_ref, Below)?; - let ws = but_graph::Graph::from_head(&repo, &meta, project_meta, Options::limited())? - .into_workspace()?; + let ws = but_graph::Workspace::from_head(&repo, &meta, project_meta, Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -2980,8 +2967,7 @@ mod ad_hoc_at_reference { ], ); - let ws = but_graph::Graph::from_head(&repo, &meta, project_meta, Options::limited())? - .into_workspace()?; + let ws = but_graph::Workspace::from_head(&repo, &meta, project_meta, Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -3050,8 +3036,7 @@ mod ad_hoc_at_reference { // is not part of the projection. Anchoring a further branch above `empty-top` would also // land above the entrypoint, so it can't be projected and is rejected. In practice the API // checks the tip out first, which is what makes stacking above it work. - let ws = but_graph::Graph::from_head(&repo, &meta, project_meta, Options::limited())? - .into_workspace()?; + let ws = but_graph::Workspace::from_head(&repo, &meta, project_meta, Options::limited())?; assert_eq!(ws.ref_name(), Some(main_ref)); let err = create( &repo, @@ -3091,8 +3076,7 @@ mod ad_hoc_at_reference { assert_order(&meta, main_ref, &expected); // ...and the workspace re-projects identically from the reloaded metadata. - let ws = but_graph::Graph::from_head(&repo, &meta, project_meta, Options::limited())? - .into_workspace()?; + let ws = but_graph::Workspace::from_head(&repo, &meta, project_meta, Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -3216,8 +3200,7 @@ mod ad_hoc_at_reference { ); // Mix in an `Above` insertion: a new tip over the (still checked-out) `main`. - let ws = but_graph::Graph::from_head(&repo, &meta, project_meta, Options::limited())? - .into_workspace()?; + let ws = but_graph::Workspace::from_head(&repo, &meta, project_meta, Options::limited())?; create(&repo, &ws, &mut meta, crown, main_ref, Above)?; assert_order( &meta, diff --git a/crates/but-workspace/tests/workspace/branch/integrate_branch_upstream.rs b/crates/but-workspace/tests/workspace/branch/integrate_branch_upstream.rs index 9ae8a83606f..ffc77680905 100644 --- a/crates/but-workspace/tests/workspace/branch/integrate_branch_upstream.rs +++ b/crates/but-workspace/tests/workspace/branch/integrate_branch_upstream.rs @@ -1,10 +1,10 @@ -use snapbox::IntoData; +use snapbox::prelude::*; use std::vec; use anyhow::{Result, bail}; use bstr::ByteSlice; use but_core::{ChangeId, Commit, RefMetadata, commit::Headers}; -use but_graph::init::Options; +use but_graph::walk::Options; use but_testsupport::gix_testtools::tempfile::TempDir; use but_testsupport::{InMemoryRefMetadata, visualize_commit_graph_all, visualize_tree}; use but_workspace::branch::integrate_branch_upstream::{ @@ -182,17 +182,17 @@ fn initial_integration_for_branch_with_strategy( strategy: BranchIntegrationStrategy, ) -> Result { let mut meta = InMemoryRefMetadata::default(); - let graph = integration_graph_for_branch(ref_name, repo, target_ref_name, &meta)?; - let mut workspace = graph.into_workspace()?; - get_initial_integration_steps_for_branch(ref_name, strategy, &mut workspace, &mut meta, repo) + let workspace = + integration_workspace_for_branch_with_meta(ref_name, repo, target_ref_name, &meta)?; + get_initial_integration_steps_for_branch(ref_name, strategy, &workspace, &mut meta, repo) } -fn integration_graph_for_branch( +fn integration_workspace_for_branch_with_meta( ref_name: &gix::refs::FullNameRef, repo: &gix::Repository, target_ref_name: Option<&gix::refs::FullNameRef>, meta: &InMemoryRefMetadata, -) -> Result { +) -> Result { if let Some(target_ref_name) = target_ref_name { let head = repo.head()?; let (head_id, head_ref_name) = match head.kind { @@ -210,12 +210,12 @@ fn integration_graph_for_branch( .find_reference(upstream_ref_name.as_ref())? .id() .detach(); - but_graph::Graph::from_commit_traversal_tips( + but_graph::Workspace::from_seeds( repo, [ - but_graph::init::Tip::entrypoint(head_id, head_ref_name), - but_graph::init::Tip::reachable(upstream_id, Some(upstream_ref_name.into_owned())), - but_graph::init::Tip::integrated(target_id, Some(target_ref_name.to_owned())), + but_graph::walk::Seed::entrypoint(head_id, head_ref_name), + but_graph::walk::Seed::reachable(upstream_id, Some(upstream_ref_name.into_owned())), + but_graph::walk::Seed::integrated(target_id, Some(target_ref_name.to_owned())), ], meta, project_meta(meta), @@ -236,18 +236,18 @@ fn integration_graph_for_branch( .find_reference(upstream_ref_name.as_ref())? .id() .detach(); - but_graph::Graph::from_commit_traversal_tips( + but_graph::Workspace::from_seeds( repo, [ - but_graph::init::Tip::entrypoint(head_id, head_ref_name), - but_graph::init::Tip::reachable(upstream_id, Some(upstream_ref_name.into_owned())), + but_graph::walk::Seed::entrypoint(head_id, head_ref_name), + but_graph::walk::Seed::reachable(upstream_id, Some(upstream_ref_name.into_owned())), ], meta, project_meta(meta), Options::limited(), ) } else { - but_graph::Graph::from_head(repo, meta, project_meta(meta), Options::limited()) + but_graph::Workspace::from_head(repo, meta, project_meta(meta), Options::limited()) } } @@ -257,8 +257,9 @@ fn integration_workspace_for_branch( target_ref_name: Option<&gix::refs::FullNameRef>, ) -> Result<(but_graph::Workspace, InMemoryRefMetadata)> { let meta = InMemoryRefMetadata::default(); - let graph = integration_graph_for_branch(ref_name, repo, target_ref_name, &meta)?; - Ok((graph.into_workspace()?, meta)) + let workspace = + integration_workspace_for_branch_with_meta(ref_name, repo, target_ref_name, &meta)?; + Ok((workspace, meta)) } #[test] @@ -928,7 +929,7 @@ pick remote-tip #[test] fn integrate_branch_with_steps_empty_errors_early() -> Result<()> { - let (_tmp, graph, mut repo, mut meta, _description) = + let (_tmp, ws, mut repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-ws-commit-single-stack-double-stack", |meta| { @@ -951,7 +952,6 @@ fn integrate_branch_with_steps_empty_errors_early() -> Result<()> { .raw() ); - let mut ws = graph.into_workspace()?; let merge_base = repo.rev_parse_single("main")?.detach(); let integration = InteractiveIntegration { merge_base, @@ -961,9 +961,8 @@ fn integrate_branch_with_steps_empty_errors_early() -> Result<()> { configure_tracking_for_branch_a(&mut repo)?; - let err = - integrate_branch_with_steps(r("refs/heads/B"), integration, &mut ws, &mut meta, &repo) - .expect_err("expected early validation error for empty integration steps"); + let err = integrate_branch_with_steps(r("refs/heads/B"), integration, &ws, &mut meta, &repo) + .expect_err("expected early validation error for empty integration steps"); assert!( err.to_string() .contains("Integration steps cannot be empty"), @@ -975,7 +974,7 @@ fn integrate_branch_with_steps_empty_errors_early() -> Result<()> { #[test] fn integrate_branch_with_merge_step_does_not_require_preceding_commit() -> Result<()> { - let (_tmp, graph, mut repo, mut meta, _description) = + let (_tmp, ws, mut repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "remote-diverged-with-workspace", |meta| { @@ -997,8 +996,6 @@ fn integrate_branch_with_merge_step_does_not_require_preceding_commit() -> Resul "#]] ); - let mut ws = graph.into_workspace()?; - let remote_commit_1 = repo.rev_parse_single("origin/A~1")?.detach(); let local_commit_1 = repo.rev_parse_single("A~1")?.detach(); let local_and_remote = repo.rev_parse_single("A~2")?.detach(); @@ -1014,7 +1011,7 @@ fn integrate_branch_with_merge_step_does_not_require_preceding_commit() -> Resul configure_tracking_for_branch_a(&mut repo)?; let rebase = - integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + integrate_branch_with_steps(r("refs/heads/A"), integration, &ws, &mut meta, &repo)?; rebase.materialize()?; snapbox::assert_data_eq!( @@ -1044,7 +1041,7 @@ fn integrate_branch_with_merge_step_does_not_require_preceding_commit() -> Resul #[test] fn integrate_upstream_commits_into_local() -> Result<()> { - let (_tmp, graph, mut repo, mut meta, _description) = + let (_tmp, ws, mut repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "remote-diverged-with-workspace", |meta| { @@ -1066,8 +1063,6 @@ fn integrate_upstream_commits_into_local() -> Result<()> { "#]] ); - let mut ws = graph.into_workspace()?; - let local_commit_2 = repo.rev_parse_single("A")?.detach(); let local_commit_1 = repo.rev_parse_single("A~1")?.detach(); let remote_commit_2 = repo.rev_parse_single("origin/A")?.detach(); @@ -1098,7 +1093,7 @@ fn integrate_upstream_commits_into_local() -> Result<()> { configure_tracking_for_branch_a(&mut repo)?; let rebase = - integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + integrate_branch_with_steps(r("refs/heads/A"), integration, &ws, &mut meta, &repo)?; rebase.materialize()?; snapbox::assert_data_eq!( @@ -1125,7 +1120,7 @@ fn integrate_upstream_commits_into_local() -> Result<()> { #[test] fn integrate_upstream_commits_into_local_with_merge_step() -> Result<()> { - let (_tmp, graph, mut repo, mut meta, _description) = + let (_tmp, ws, mut repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "remote-diverged-with-workspace", |meta| { @@ -1147,8 +1142,6 @@ fn integrate_upstream_commits_into_local_with_merge_step() -> Result<()> { "#]] ); - let mut ws = graph.into_workspace()?; - let local_commit_2 = repo.rev_parse_single("A")?.detach(); let local_commit_1 = repo.rev_parse_single("A~1")?.detach(); let remote_commit_1 = repo.rev_parse_single("origin/A~1")?.detach(); @@ -1172,7 +1165,7 @@ fn integrate_upstream_commits_into_local_with_merge_step() -> Result<()> { configure_tracking_for_branch_a(&mut repo)?; let rebase = - integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + integrate_branch_with_steps(r("refs/heads/A"), integration, &ws, &mut meta, &repo)?; rebase.materialize()?; snapbox::assert_data_eq!( @@ -1237,7 +1230,7 @@ fn integrate_upstream_commits_into_local_with_merge_step() -> Result<()> { #[test] fn integrate_upstream_commits_into_local_with_all_locals_then_merge_second_remote() -> Result<()> { - let (_tmp, graph, mut repo, mut meta, _description) = + let (_tmp, ws, mut repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "remote-diverged-with-workspace", |meta| { @@ -1245,8 +1238,6 @@ fn integrate_upstream_commits_into_local_with_all_locals_then_merge_second_remot }, )?; - let mut ws = graph.into_workspace()?; - let local_commit_2 = repo.rev_parse_single("A")?.detach(); let local_commit_1 = repo.rev_parse_single("A~1")?.detach(); let remote_commit_2 = repo.rev_parse_single("origin/A")?.detach(); @@ -1270,7 +1261,7 @@ fn integrate_upstream_commits_into_local_with_all_locals_then_merge_second_remot configure_tracking_for_branch_a(&mut repo)?; let rebase = - integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + integrate_branch_with_steps(r("refs/heads/A"), integration, &ws, &mut meta, &repo)?; rebase.materialize()?; snapbox::assert_data_eq!( @@ -1308,7 +1299,7 @@ fn integrate_upstream_commits_into_local_with_all_locals_then_merge_second_remot #[test] fn integrate_upstream_commits_into_local_with_two_merges_in_sequence() -> Result<()> { - let (_tmp, graph, mut repo, mut meta, _description) = + let (_tmp, ws, mut repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "remote-diverged-with-workspace", |meta| { @@ -1316,8 +1307,6 @@ fn integrate_upstream_commits_into_local_with_two_merges_in_sequence() -> Result }, )?; - let mut ws = graph.into_workspace()?; - let local_commit_2 = repo.rev_parse_single("A")?.detach(); let local_commit_1 = repo.rev_parse_single("A~1")?.detach(); let remote_commit_1 = repo.rev_parse_single("origin/A~1")?.detach(); @@ -1345,7 +1334,7 @@ fn integrate_upstream_commits_into_local_with_two_merges_in_sequence() -> Result configure_tracking_for_branch_a(&mut repo)?; let rebase = - integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + integrate_branch_with_steps(r("refs/heads/A"), integration, &ws, &mut meta, &repo)?; rebase.materialize()?; snapbox::assert_data_eq!( @@ -1401,7 +1390,7 @@ fn integrate_upstream_commits_into_local_with_two_merges_in_sequence() -> Result #[test] fn integrate_upstream_commits_into_local_with_remote_on_top() -> Result<()> { - let (_tmp, graph, mut repo, mut meta, _description) = + let (_tmp, ws, mut repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "remote-diverged-with-workspace", |meta| { @@ -1423,8 +1412,6 @@ fn integrate_upstream_commits_into_local_with_remote_on_top() -> Result<()> { "#]] ); - let mut ws = graph.into_workspace()?; - let local_commit_2 = repo.rev_parse_single("A")?.detach(); let local_commit_1 = repo.rev_parse_single("A~1")?.detach(); let remote_commit_2 = repo.rev_parse_single("origin/A")?.detach(); @@ -1454,7 +1441,7 @@ fn integrate_upstream_commits_into_local_with_remote_on_top() -> Result<()> { configure_tracking_for_branch_a(&mut repo)?; let rebase = - integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + integrate_branch_with_steps(r("refs/heads/A"), integration, &ws, &mut meta, &repo)?; rebase.materialize()?; snapbox::assert_data_eq!( @@ -1478,7 +1465,7 @@ fn integrate_upstream_commits_into_local_with_remote_on_top() -> Result<()> { #[test] fn integrate_upstream_commits_into_local_with_remote_interlaced() -> Result<()> { - let (_tmp, graph, mut repo, mut meta, _description) = + let (_tmp, ws, mut repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "remote-diverged-with-workspace", |meta| { @@ -1500,8 +1487,6 @@ fn integrate_upstream_commits_into_local_with_remote_interlaced() -> Result<()> "#]] ); - let mut ws = graph.into_workspace()?; - let local_commit_2 = repo.rev_parse_single("A")?.detach(); let local_commit_1 = repo.rev_parse_single("A~1")?.detach(); let remote_commit_2 = repo.rev_parse_single("origin/A")?.detach(); @@ -1531,7 +1516,7 @@ fn integrate_upstream_commits_into_local_with_remote_interlaced() -> Result<()> configure_tracking_for_branch_a(&mut repo)?; let rebase = - integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + integrate_branch_with_steps(r("refs/heads/A"), integration, &ws, &mut meta, &repo)?; rebase.materialize()?; snapbox::assert_data_eq!( @@ -1554,7 +1539,7 @@ fn integrate_upstream_commits_into_local_with_remote_interlaced() -> Result<()> #[test] fn integrate_upstream_commits_into_local_with_remote_one_local_one_remote() -> Result<()> { - let (_tmp, graph, mut repo, mut meta, _description) = + let (_tmp, ws, mut repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "remote-diverged-with-workspace", |meta| { @@ -1576,8 +1561,6 @@ fn integrate_upstream_commits_into_local_with_remote_one_local_one_remote() -> R "#]] ); - let mut ws = graph.into_workspace()?; - let local_commit_1 = repo.rev_parse_single("A~1")?.detach(); let remote_commit_2 = repo.rev_parse_single("origin/A")?.detach(); let local_and_remote = repo.rev_parse_single("A~2")?.detach(); @@ -1599,7 +1582,7 @@ fn integrate_upstream_commits_into_local_with_remote_one_local_one_remote() -> R configure_tracking_for_branch_a(&mut repo)?; let rebase = - integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + integrate_branch_with_steps(r("refs/heads/A"), integration, &ws, &mut meta, &repo)?; rebase.materialize()?; snapbox::assert_data_eq!( @@ -1622,7 +1605,7 @@ fn integrate_upstream_commits_into_local_with_remote_one_local_one_remote() -> R #[test] fn integrate_upstream_commits_into_local_with_remote_one_local_one_remote_and_extra_local_ref() -> Result<()> { - let (_tmp, graph, mut repo, mut meta, _description) = + let (_tmp, ws, mut repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "remote-diverged-with-workspace", |meta| { @@ -1646,8 +1629,6 @@ fn integrate_upstream_commits_into_local_with_remote_one_local_one_remote_and_ex "#]] ); - let mut ws = graph.into_workspace()?; - let local_commit_1 = repo.rev_parse_single("A~1")?.detach(); let remote_commit_2 = repo.rev_parse_single("origin/A")?.detach(); let local_and_remote = repo.rev_parse_single("A~2")?.detach(); @@ -1669,7 +1650,7 @@ fn integrate_upstream_commits_into_local_with_remote_one_local_one_remote_and_ex configure_tracking_for_branch_a(&mut repo)?; let rebase = - integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + integrate_branch_with_steps(r("refs/heads/A"), integration, &ws, &mut meta, &repo)?; rebase.materialize()?; snapbox::assert_data_eq!( @@ -1693,7 +1674,7 @@ fn integrate_upstream_commits_into_local_with_remote_one_local_one_remote_and_ex #[test] fn integrate_upstream_commits_into_local_with_only_remote_commits() -> Result<()> { - let (_tmp, graph, mut repo, mut meta, _description) = + let (_tmp, ws, mut repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "remote-diverged-with-workspace", |meta| { @@ -1715,8 +1696,6 @@ fn integrate_upstream_commits_into_local_with_only_remote_commits() -> Result<() "#]] ); - let mut ws = graph.into_workspace()?; - let remote_commit_2 = repo.rev_parse_single("origin/A")?.detach(); let remote_commit_1 = repo.rev_parse_single("origin/A~1")?.detach(); let local_commit_1 = repo.rev_parse_single("A~1")?.detach(); @@ -1739,7 +1718,7 @@ fn integrate_upstream_commits_into_local_with_only_remote_commits() -> Result<() configure_tracking_for_branch_a(&mut repo)?; let rebase = - integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + integrate_branch_with_steps(r("refs/heads/A"), integration, &ws, &mut meta, &repo)?; rebase.materialize()?; snapbox::assert_data_eq!( @@ -1758,7 +1737,7 @@ fn integrate_upstream_commits_into_local_with_only_remote_commits() -> Result<() #[test] fn integrate_upstream_commits_when_remote_is_ahead_of_local() -> Result<()> { - let (_tmp, graph, mut repo, mut meta, _description) = + let (_tmp, ws, mut repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "remote-advanced-with-workspace", |meta| { @@ -1778,13 +1757,12 @@ fn integrate_upstream_commits_when_remote_is_ahead_of_local() -> Result<()> { "#]] ); - let mut ws = graph.into_workspace()?; configure_tracking_for_branch_a(&mut repo)?; let initial = get_initial_integration_steps_for_branch( r("refs/heads/A"), BranchIntegrationStrategy::PullRebase, - &mut ws, + &ws, &mut meta, &repo, )?; @@ -1819,7 +1797,7 @@ pick remote-commit let rebase = integrate_branch_with_steps( r("refs/heads/A"), initial.integration, - &mut ws, + &ws, &mut meta, &repo, )?; @@ -1841,7 +1819,7 @@ pick remote-commit #[test] fn integrate_remote_advanced_branch_with_parallel_empty_branch() -> Result<()> { - let (_tmp, graph, mut repo, mut meta, _description) = + let (_tmp, ws, mut repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "remote-advanced-with-empty-top-branch", |meta| { @@ -1861,13 +1839,12 @@ fn integrate_remote_advanced_branch_with_parallel_empty_branch() -> Result<()> { "#]] ); - let mut ws = graph.into_workspace()?; configure_tracking_for_branch(&mut repo, "feature-foo")?; let initial = get_initial_integration_steps_for_branch( r("refs/heads/feature-foo"), BranchIntegrationStrategy::PullRebase, - &mut ws, + &ws, &mut meta, &repo, )?; @@ -1890,7 +1867,7 @@ pick remote-commit let rebase = integrate_branch_with_steps( r("refs/heads/feature-foo"), initial.integration, - &mut ws, + &ws, &mut meta, &repo, )?; @@ -1899,10 +1876,10 @@ pick remote-commit snapbox::assert_data_eq!( labeled_graph_snapshot(&repo, &labels)?, snapbox::str![[r#" -* abcd9a1 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 9bafbf1 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -| * remote-commit (origin/feature-foo, feature-foo) update foo.txt (remote) -| * local-commit add foo.txt +* | remote-commit (origin/feature-foo, feature-foo) update foo.txt (remote) +* | local-commit add foo.txt |/ * da7bed3 (origin/main, main, empty-branch) add main.txt "#]] @@ -1914,7 +1891,7 @@ pick remote-commit #[test] fn initial_pull_rebase_plan_includes_workspace_local_commits_above_branch_ref() -> Result<()> { - let (_tmp, graph, mut repo, mut meta, _description) = + let (_tmp, ws, mut repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "remote-advanced-with-local-workspace-commit", |meta| { @@ -1935,13 +1912,12 @@ fn initial_pull_rebase_plan_includes_workspace_local_commits_above_branch_ref() "#]] ); - let mut ws = graph.into_workspace()?; configure_tracking_for_branch_a(&mut repo)?; let initial = get_initial_integration_steps_for_branch( r("refs/heads/A"), BranchIntegrationStrategy::PullRebase, - &mut ws, + &ws, &mut meta, &repo, )?; @@ -1981,7 +1957,7 @@ pick local-commit-3 let rebase = integrate_branch_with_steps( r("refs/heads/A"), initial.integration, - &mut ws, + &ws, &mut meta, &repo, )?; @@ -2004,7 +1980,7 @@ pick local-commit-3 #[test] fn integrate_initial_pull_rebase_plan_for_one_local_and_one_remote_commit() -> Result<()> { - let (_tmp, graph, mut repo, mut meta, _description) = + let (_tmp, ws, mut repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "remote-diverged-with-workspace", |meta| { @@ -2012,13 +1988,12 @@ fn integrate_initial_pull_rebase_plan_for_one_local_and_one_remote_commit() -> R }, )?; - let mut ws = graph.into_workspace()?; configure_tracking_for_branch_a(&mut repo)?; let initial = get_initial_integration_steps_for_branch( r("refs/heads/A"), BranchIntegrationStrategy::PullRebase, - &mut ws, + &ws, &mut meta, &repo, )?; @@ -2050,7 +2025,7 @@ pick local-tip let rebase = integrate_branch_with_steps( r("refs/heads/A"), initial.integration, - &mut ws, + &ws, &mut meta, &repo, )?; @@ -2074,7 +2049,7 @@ pick local-tip #[test] fn integrate_upstream_commits_into_local_with_squashed_local_commits() -> Result<()> { - let (_tmp, graph, mut repo, mut meta, _description) = + let (_tmp, ws, mut repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "remote-diverged-with-workspace", |meta| { @@ -2082,8 +2057,6 @@ fn integrate_upstream_commits_into_local_with_squashed_local_commits() -> Result }, )?; - let mut ws = graph.into_workspace()?; - let local_commit_2 = repo.rev_parse_single("A")?.detach(); let local_commit_1 = repo.rev_parse_single("A~1")?.detach(); let remote_commit_2 = repo.rev_parse_single("origin/A")?.detach(); @@ -2111,7 +2084,7 @@ fn integrate_upstream_commits_into_local_with_squashed_local_commits() -> Result configure_tracking_for_branch_a(&mut repo)?; let rebase = - integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + integrate_branch_with_steps(r("refs/heads/A"), integration, &ws, &mut meta, &repo)?; rebase.materialize()?; snapbox::assert_data_eq!( @@ -2134,7 +2107,7 @@ fn integrate_upstream_commits_into_local_with_squashed_local_commits() -> Result #[test] fn integrate_upstream_commits_into_local_with_squashed_remote_commits() -> Result<()> { - let (_tmp, graph, mut repo, mut meta, _description) = + let (_tmp, ws, mut repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "remote-diverged-with-workspace", |meta| { @@ -2142,8 +2115,6 @@ fn integrate_upstream_commits_into_local_with_squashed_remote_commits() -> Resul }, )?; - let mut ws = graph.into_workspace()?; - let local_commit_2 = repo.rev_parse_single("A")?.detach(); let local_commit_1 = repo.rev_parse_single("A~1")?.detach(); let remote_commit_2 = repo.rev_parse_single("origin/A")?.detach(); @@ -2171,7 +2142,7 @@ fn integrate_upstream_commits_into_local_with_squashed_remote_commits() -> Resul configure_tracking_for_branch_a(&mut repo)?; let rebase = - integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + integrate_branch_with_steps(r("refs/heads/A"), integration, &ws, &mut meta, &repo)?; rebase.materialize()?; snapbox::assert_data_eq!( @@ -2197,7 +2168,7 @@ fn integrate_upstream_commits_into_local_with_squashed_remote_commits() -> Resul #[test] fn integrate_upstream_commits_into_local_with_squashed_remote_into_local_commits() -> Result<()> { - let (_tmp, graph, mut repo, mut meta, _description) = + let (_tmp, ws, mut repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "remote-diverged-with-workspace", |meta| { @@ -2205,8 +2176,6 @@ fn integrate_upstream_commits_into_local_with_squashed_remote_into_local_commits }, )?; - let mut ws = graph.into_workspace()?; - let local_commit_2 = repo.rev_parse_single("A")?.detach(); let local_commit_1 = repo.rev_parse_single("A~1")?.detach(); let remote_commit_2 = repo.rev_parse_single("origin/A")?.detach(); @@ -2232,7 +2201,7 @@ fn integrate_upstream_commits_into_local_with_squashed_remote_into_local_commits configure_tracking_for_branch_a(&mut repo)?; let rebase = - integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + integrate_branch_with_steps(r("refs/heads/A"), integration, &ws, &mut meta, &repo)?; rebase.materialize()?; snapbox::assert_data_eq!( @@ -2254,7 +2223,7 @@ fn integrate_upstream_commits_into_local_with_squashed_remote_into_local_commits #[test] fn integrate_upstream_commits_into_local_with_squashed_remote_into_local_conflicts() -> Result<()> { - let (_tmp, graph, mut repo, mut meta, _description) = + let (_tmp, ws, mut repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "remote-diverged-with-workspace-conflicting", |meta| { @@ -2273,8 +2242,6 @@ fn integrate_upstream_commits_into_local_with_squashed_remote_into_local_conflic "#]] ); - let mut ws = graph.into_workspace()?; - let local_commit_1 = repo.rev_parse_single("A")?.detach(); let remote_commit_1 = repo.rev_parse_single("origin/A")?.detach(); let local_and_remote = repo.rev_parse_single("main")?.detach(); @@ -2292,7 +2259,7 @@ fn integrate_upstream_commits_into_local_with_squashed_remote_into_local_conflic configure_tracking_for_branch_a(&mut repo)?; let rebase = - integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + integrate_branch_with_steps(r("refs/heads/A"), integration, &ws, &mut meta, &repo)?; rebase.materialize()?; snapbox::assert_data_eq!( @@ -2325,7 +2292,9 @@ GitButler-Conflict: This is a GitButler-managed conflicted commit. Files are aut "#]] ); - snapbox::assert_data_eq!(normalized_tree_snapshot(branch_tip.tree_id()?.detach(), &repo)?, snapbox::str![[r#" + snapbox::assert_data_eq!( + normalized_tree_snapshot(branch_tip.tree_id()?.detach(), &repo)?, + snapbox::str![[r#" 450d676 โ”œโ”€โ”€ .auto-resolution:276d2b4 โ”‚ โ””โ”€โ”€ shared.txt:100644:4083037 "local\n" @@ -2337,14 +2306,15 @@ GitButler-Conflict: This is a GitButler-managed conflicted commit. Files are aut โ”œโ”€โ”€ .conflict-side-1:cd74779 โ”‚ โ””โ”€โ”€ shared.txt:100644:9c998f7 "remote\n" โ””โ”€โ”€ shared.txt:100644:4083037 "local\n" -"#]].raw()); +"#]].raw() + ); Ok(()) } #[test] fn integrate_upstream_commits_into_local_with_merge_remote_into_local_conflicts() -> Result<()> { - let (_tmp, graph, mut repo, mut meta, _description) = + let (_tmp, ws, mut repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "remote-diverged-with-workspace-conflicting", |meta| { @@ -2363,8 +2333,6 @@ fn integrate_upstream_commits_into_local_with_merge_remote_into_local_conflicts( "#]] ); - let mut ws = graph.into_workspace()?; - let local_commit_1 = repo.rev_parse_single("A")?.detach(); let remote_commit_1 = repo.rev_parse_single("origin/A")?.detach(); let local_and_remote = repo.rev_parse_single("main")?.detach(); @@ -2384,7 +2352,7 @@ fn integrate_upstream_commits_into_local_with_merge_remote_into_local_conflicts( configure_tracking_for_branch_a(&mut repo)?; let rebase = - integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + integrate_branch_with_steps(r("refs/heads/A"), integration, &ws, &mut meta, &repo)?; rebase.materialize()?; snapbox::assert_data_eq!( @@ -2433,7 +2401,9 @@ fn integrate_upstream_commits_into_local_with_merge_remote_into_local_conflicts( "merge integration should retain the local commit as first parent even when conflicted", ); - snapbox::assert_data_eq!(normalized_tree_snapshot(branch_tip.tree_id()?.detach(), &repo)?, snapbox::str![[r#" + snapbox::assert_data_eq!( + normalized_tree_snapshot(branch_tip.tree_id()?.detach(), &repo)?, + snapbox::str![[r#" 450d676 โ”œโ”€โ”€ .auto-resolution:276d2b4 โ”‚ โ””โ”€โ”€ shared.txt:100644:4083037 "local\n" @@ -2445,7 +2415,8 @@ fn integrate_upstream_commits_into_local_with_merge_remote_into_local_conflicts( โ”œโ”€โ”€ .conflict-side-1:cd74779 โ”‚ โ””โ”€โ”€ shared.txt:100644:9c998f7 "remote\n" โ””โ”€โ”€ shared.txt:100644:4083037 "local\n" -"#]].raw()); +"#]].raw() + ); Ok(()) } @@ -2453,7 +2424,7 @@ fn integrate_upstream_commits_into_local_with_merge_remote_into_local_conflicts( #[test] fn integrate_upstream_commits_into_local_with_merge_remote_into_local_conflicts_preview() -> Result<()> { - let (_tmp, graph, mut repo, mut meta, _description) = + let (_tmp, ws, mut repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "remote-diverged-with-workspace-conflicting", |meta| { @@ -2461,8 +2432,6 @@ fn integrate_upstream_commits_into_local_with_merge_remote_into_local_conflicts_ }, )?; - let mut ws = graph.into_workspace()?; - let local_commit_1 = repo.rev_parse_single("A")?.detach(); let remote_commit_1 = repo.rev_parse_single("origin/A")?.detach(); let local_and_remote = repo.rev_parse_single("main")?.detach(); @@ -2482,14 +2451,13 @@ fn integrate_upstream_commits_into_local_with_merge_remote_into_local_conflicts_ configure_tracking_for_branch_a(&mut repo)?; let rebase = - integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; - let preview_graph = rebase.overlayed_graph()?; - let preview_workspace = preview_graph.into_workspace()?; + integrate_branch_with_steps(r("refs/heads/A"), integration, &ws, &mut meta, &repo)?; + let preview_workspace = but_workspace::workspace::overlayed_workspace(&ws, &rebase)?; let ref_info = but_workspace::graph_to_ref_info( &preview_workspace, rebase.repo(), but_workspace::ref_info::Options { - traversal: but_graph::init::Options::limited(), + traversal: but_graph::walk::Options::limited(), expensive_commit_info: true, ..Default::default() }, @@ -2505,8 +2473,8 @@ fn integrate_upstream_commits_into_local_with_merge_remote_into_local_conflicts_ } #[test] -fn integrate_upstream_precomputes_squash_before_later_step_graph_rewiring() -> Result<()> { - let (_tmp, graph, mut repo, mut meta, _description) = +fn integrate_upstream_precomputes_squash_before_later_graph_rewiring() -> Result<()> { + let (_tmp, ws, mut repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "remote-diverged-with-workspace", |meta| { @@ -2514,8 +2482,6 @@ fn integrate_upstream_precomputes_squash_before_later_step_graph_rewiring() -> R }, )?; - let mut ws = graph.into_workspace()?; - let local_commit_2 = repo.rev_parse_single("A")?.detach(); let local_commit_1 = repo.rev_parse_single("A~1")?.detach(); let local_and_remote = repo.rev_parse_single("A~2")?.detach(); @@ -2539,7 +2505,7 @@ fn integrate_upstream_precomputes_squash_before_later_step_graph_rewiring() -> R configure_tracking_for_branch_a(&mut repo)?; let rebase = - integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + integrate_branch_with_steps(r("refs/heads/A"), integration, &ws, &mut meta, &repo)?; rebase.materialize()?; let mut current_commit_id = repo.rev_parse_single("A")?.detach(); @@ -2815,7 +2781,7 @@ fn apply_initial_steps_example_2_also_applies_integrated_local_commits() -> Resu "example 2 should still omit integrated local commits from the editable plan" ); - let (mut workspace, mut meta) = integration_workspace_for_branch( + let (workspace, mut meta) = integration_workspace_for_branch( r("refs/heads/A"), &repo, Some(r("refs/remotes/origin/main")), @@ -2823,7 +2789,7 @@ fn apply_initial_steps_example_2_also_applies_integrated_local_commits() -> Resu let rebase = integrate_branch_with_steps( r("refs/heads/A"), initial.integration, - &mut workspace, + &workspace, &mut meta, &repo, )?; diff --git a/crates/but-workspace/tests/workspace/branch/move_branch.rs b/crates/but-workspace/tests/workspace/branch/move_branch.rs index 3c9177d5d2b..d1414137921 100644 --- a/crates/but-workspace/tests/workspace/branch/move_branch.rs +++ b/crates/but-workspace/tests/workspace/branch/move_branch.rs @@ -1,9 +1,9 @@ use but_core::RefMetadata; use but_core::ref_metadata::StackKind; -use but_graph::init::Options; +use but_graph::walk::Options; use but_rebase::graph_rebase::Editor; use but_testsupport::{graph_workspace, invoke_bash, visualize_commit_graph_all}; -use snapbox::IntoData; +use snapbox::prelude::*; use crate::ref_info::with_workspace_commit::utils::{ StackState, add_stack_with_segments, named_writable_scenario_with_description, @@ -12,7 +12,7 @@ use crate::ref_info::with_workspace_commit::utils::{ #[test] fn move_top_branch_to_top_of_another_stack() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-ws-commit-single-stack-double-stack", |meta| { @@ -35,47 +35,48 @@ fn move_top_branch_to_top_of_another_stack() -> anyhow::Result<()> { .raw() ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; // Put C on top of A let but_workspace::branch::move_branch::Outcome { rebase, ws_meta, .. } = but_workspace::branch::move_branch( editor, + &ws, "refs/heads/C".try_into()?, "refs/heads/A".try_into()?, )?; // Materialize the operation - rebase.materialize()?; + let materialized = rebase.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; set_workspace_metadata(&mut meta, &ws, ws_meta)?; - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* 0ffeac6 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* bdcbf64 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -| * f2cc60d (C) C -| * 09d8e52 (A) A -* | c813d8d (B) B +| * c813d8d (B) B +* | f2cc60d (C) C +* | 09d8e52 (A) A |/ * 85efbe4 (origin/main, main) M @@ -104,7 +105,7 @@ fn move_top_branch_to_top_of_another_stack() -> anyhow::Result<()> { #[test] fn moving_branch_onto_itself_fails_without_changing_workspace() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-ws-commit-single-stack-double-stack", |meta| { @@ -113,12 +114,12 @@ fn moving_branch_onto_itself_fails_without_changing_workspace() -> anyhow::Resul }, )?; - let mut ws = graph.into_workspace()?; let before = graph_workspace(&ws).to_string(); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let err = but_workspace::branch::move_branch( editor, + &ws, "refs/heads/C".try_into()?, "refs/heads/C".try_into()?, ) @@ -139,7 +140,7 @@ fn moving_branch_onto_itself_fails_without_changing_workspace() -> anyhow::Resul #[test] fn move_bottom_branch_to_top_of_another_stack() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-ws-commit-single-stack-double-stack", |meta| { @@ -162,36 +163,37 @@ fn move_bottom_branch_to_top_of_another_stack() -> anyhow::Result<()> { .raw() ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let but_workspace::branch::move_branch::Outcome { rebase, ws_meta, .. } = but_workspace::branch::move_branch( editor, + &ws, "refs/heads/B".try_into()?, "refs/heads/A".try_into()?, )?; // Materialize the operation - rebase.materialize()?; + let materialized = rebase.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; set_workspace_metadata(&mut meta, &ws, ws_meta)?; - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; snapbox::assert_data_eq!( @@ -213,14 +215,14 @@ fn move_bottom_branch_to_top_of_another_stack() -> anyhow::Result<()> { graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {1} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B -โ”‚ โ”‚ โ””โ”€โ”€ ยทf9061ed (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} - โ””โ”€โ”€ ๐Ÿ“™:C - โ””โ”€โ”€ ยท8e00332 (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:C +โ”‚ โ””โ”€โ”€ ยท8e00332 (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {1} + โ”œโ”€โ”€ ๐Ÿ“™:B + โ”‚ โ””โ”€โ”€ ยทf9061ed (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] ); @@ -230,7 +232,7 @@ fn move_bottom_branch_to_top_of_another_stack() -> anyhow::Result<()> { #[test] fn move_single_branch_to_top_of_another_stack() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-ws-commit-single-stack-double-stack", |meta| { @@ -253,37 +255,38 @@ fn move_single_branch_to_top_of_another_stack() -> anyhow::Result<()> { .raw() ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; // Put A on top of C let but_workspace::branch::move_branch::Outcome { rebase, ws_meta, .. } = but_workspace::branch::move_branch( editor, + &ws, "refs/heads/A".try_into()?, "refs/heads/C".try_into()?, )?; // Materialize the operation - rebase.materialize()?; + let materialized = rebase.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; set_workspace_metadata(&mut meta, &ws, ws_meta)?; - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; snapbox::assert_data_eq!( @@ -318,7 +321,7 @@ fn move_single_branch_to_top_of_another_stack() -> anyhow::Result<()> { #[test] fn reorder_branch_in_stack() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-ws-commit-single-stack-double-stack", |meta| { @@ -341,37 +344,38 @@ fn reorder_branch_in_stack() -> anyhow::Result<()> { .raw() ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; // Put B on top of C let but_workspace::branch::move_branch::Outcome { rebase, ws_meta, .. } = but_workspace::branch::move_branch( editor, + &ws, "refs/heads/B".try_into()?, "refs/heads/C".try_into()?, )?; // Materialize the operation - rebase.materialize()?; + let materialized = rebase.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; set_workspace_metadata(&mut meta, &ws, ws_meta)?; - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; snapbox::assert_data_eq!( @@ -393,14 +397,14 @@ fn reorder_branch_in_stack() -> anyhow::Result<()> { graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:B - โ”‚ โ””โ”€โ”€ ยทde0581e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:C - โ””โ”€โ”€ ยท8e00332 (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ”‚ โ””โ”€โ”€ ยทde0581e (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:C +โ”‚ โ””โ”€โ”€ ยท8e00332 (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] ); @@ -410,7 +414,7 @@ fn reorder_branch_in_stack() -> anyhow::Result<()> { #[test] fn insert_branch_in_the_middle_of_a_stack() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-ws-commit-single-stack-double-stack", |meta| { @@ -433,37 +437,38 @@ fn insert_branch_in_the_middle_of_a_stack() -> anyhow::Result<()> { .raw() ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; // Put A on top of B, and below C let but_workspace::branch::move_branch::Outcome { rebase, ws_meta, .. } = but_workspace::branch::move_branch( editor, + &ws, "refs/heads/A".try_into()?, "refs/heads/B".try_into()?, )?; // Materialize the operation - rebase.materialize()?; + let materialized = rebase.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; set_workspace_metadata(&mut meta, &ws, ws_meta)?; - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; snapbox::assert_data_eq!( @@ -498,7 +503,7 @@ fn insert_branch_in_the_middle_of_a_stack() -> anyhow::Result<()> { #[test] fn move_empty_branch() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph("ws-with-empty-stack", |meta| { add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); add_stack_with_segments(meta, 2, "B", StackState::InWorkspace, &[]); @@ -516,34 +521,35 @@ fn move_empty_branch() -> anyhow::Result<()> { .raw() ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} - โ””โ”€โ”€ ๐Ÿ“™:B +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; // Put B on top of A let but_workspace::branch::move_branch::Outcome { rebase, ws_meta, .. } = but_workspace::branch::move_branch( editor, + &ws, "refs/heads/B".try_into()?, "refs/heads/A".try_into()?, )?; // Materialize the operation - rebase.materialize()?; + let materialized = rebase.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; set_workspace_metadata(&mut meta, &ws, ws_meta)?; - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; snapbox::assert_data_eq!( @@ -572,7 +578,7 @@ fn move_empty_branch() -> anyhow::Result<()> { #[test] fn move_branch_on_top_of_empty_branch() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph("ws-with-empty-stack", |meta| { add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); add_stack_with_segments(meta, 2, "B", StackState::InWorkspace, &[]); @@ -590,34 +596,35 @@ fn move_branch_on_top_of_empty_branch() -> anyhow::Result<()> { .raw() ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} - โ””โ”€โ”€ ๐Ÿ“™:B +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; // Put A on top of B let but_workspace::branch::move_branch::Outcome { rebase, ws_meta, .. } = but_workspace::branch::move_branch( editor, + &ws, "refs/heads/A".try_into()?, "refs/heads/B".try_into()?, )?; // Materialize the operation - rebase.materialize()?; + let materialized = rebase.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; set_workspace_metadata(&mut meta, &ws, ws_meta)?; - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; snapbox::assert_data_eq!( @@ -657,7 +664,7 @@ fn move_empty_branch_on_top_of_empty_branch_in_same_stack() -> anyhow::Result<() let project_meta = meta .workspace(but_core::WORKSPACE_REF_NAME.try_into()?)? .project_meta(); - let graph = but_graph::Graph::from_head( + let mut ws = but_graph::Workspace::from_head( &repo, &meta, project_meta, @@ -669,8 +676,6 @@ fn move_empty_branch_on_top_of_empty_branch_in_same_stack() -> anyhow::Result<() ..Options::limited() }, )?; - - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -682,18 +687,20 @@ fn move_empty_branch_on_top_of_empty_branch_in_same_stack() -> anyhow::Result<() "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let but_workspace::branch::move_branch::Outcome { rebase, ws_meta, .. } = but_workspace::branch::move_branch( editor, + &ws, "refs/heads/A".try_into()?, "refs/heads/B".try_into()?, )?; - rebase.materialize()?; + let materialized = rebase.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; set_workspace_metadata(&mut meta, &ws, ws_meta)?; - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; snapbox::assert_data_eq!( @@ -724,7 +731,7 @@ fn move_empty_branch_on_top_of_empty_branch_across_stacks() -> anyhow::Result<() let project_meta = meta .workspace(but_core::WORKSPACE_REF_NAME.try_into()?)? .project_meta(); - let graph = but_graph::Graph::from_head( + let mut ws = but_graph::Workspace::from_head( &repo, &meta, project_meta, @@ -736,8 +743,6 @@ fn move_empty_branch_on_top_of_empty_branch_across_stacks() -> anyhow::Result<() ..Options::limited() }, )?; - - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -750,18 +755,20 @@ fn move_empty_branch_on_top_of_empty_branch_across_stacks() -> anyhow::Result<() "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let but_workspace::branch::move_branch::Outcome { rebase, ws_meta, .. } = but_workspace::branch::move_branch( editor, + &ws, "refs/heads/A".try_into()?, "refs/heads/B".try_into()?, )?; - rebase.materialize()?; + let materialized = rebase.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; set_workspace_metadata(&mut meta, &ws, ws_meta)?; - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; snapbox::assert_data_eq!( @@ -779,8 +786,8 @@ fn move_empty_branch_on_top_of_empty_branch_across_stacks() -> anyhow::Result<() } #[test] -fn non_empty_move_updates_metadata_and_keeps_display_order_aligned() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = +fn non_empty_move_display_order_follows_workspace_parents() -> anyhow::Result<()> { + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-ws-commit-single-stack-double-stack", |meta| { @@ -804,36 +811,38 @@ fn non_empty_move_updates_metadata_and_keeps_display_order_aligned() -> anyhow:: .raw() ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] ); let before_display_order = stack_display_order(&ws); let before_metadata_order = metadata_stack_order(&ws); + // Display order is the workspace commit's parent array; the fixture's declared metadata + // order lags behind it until the next metadata write. assert_eq!( - before_display_order, before_metadata_order, - "workspace projection order should match metadata before moving now that stack order is no longer reversed downstream" + before_display_order, + ["refs/heads/C", "refs/heads/A"].map(str::to_owned) ); // Move non-empty C on top of non-empty A. // This rewrites metadata and keeps display + metadata aligned. - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let but_workspace::branch::move_branch::Outcome { rebase, ws_meta, .. } = but_workspace::branch::move_branch( editor, + &ws, "refs/heads/C".try_into()?, "refs/heads/A".try_into()?, )?; @@ -843,7 +852,8 @@ fn non_empty_move_updates_metadata_and_keeps_display_order_aligned() -> anyhow:: .map(|ws_meta| workspace_metadata_stack_order(ws_meta, StackKind::Applied)) .unwrap_or_default(); - rebase.materialize()?; + let materialized = rebase.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; set_workspace_metadata(&mut meta, &ws, ws_meta)?; // before refreshing `ws` the pure-virtual change isn't visible (should be fixed once meta is in db!) @@ -851,18 +861,18 @@ fn non_empty_move_updates_metadata_and_keeps_display_order_aligned() -> anyhow:: graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B -โ”‚ โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {1} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ”‚ โ””โ”€โ”€ ยทf2cc60d (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:A - โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”‚ โ””โ”€โ”€ ยทf2cc60d (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 + โ””โ”€โ”€ ๐Ÿ“™:B + โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] ); - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; // after the refresh the workspace is finally uptodate (this will probably be an issue unless callers know that) snapbox::assert_data_eq!( @@ -883,19 +893,19 @@ fn non_empty_move_updates_metadata_and_keeps_display_order_aligned() -> anyhow:: let after_display_order = stack_display_order(&ws); + // The move changes both the stored metadata order and the displayed order. assert_ne!(updated_metadata_order, before_metadata_order); assert_ne!(after_display_order, before_display_order); - assert_eq!( - after_display_order, updated_metadata_order, - "workspace projection order should match metadata after moving now that stack order is no longer reversed downstream" - ); + // Stack order is taken from the workspace-commit parent array (the source of + // truth), not metadata order: after the move the parents are [B, C] while + // metadata is [C, B]. The order snapshots below capture the parent-array order. snapbox::assert_data_eq!( format!("{before_display_order:#?}"), snapbox::str![[r#" [ - "refs/heads/A", "refs/heads/C", + "refs/heads/A", ] "#]] ); @@ -914,8 +924,8 @@ fn non_empty_move_updates_metadata_and_keeps_display_order_aligned() -> anyhow:: } #[test] -fn empty_move_keeps_display_order_aligned_with_metadata() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = +fn empty_move_display_order_follows_workspace_parents() -> anyhow::Result<()> { + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph("ws-with-empty-stack", |meta| { add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); add_stack_with_segments(meta, 2, "B", StackState::InWorkspace, &[]); @@ -934,18 +944,23 @@ fn empty_move_keeps_display_order_aligned_with_metadata() -> anyhow::Result<()> .raw() ); - let mut ws = graph.into_workspace()?; let before_display_order = stack_display_order(&ws); let before_metadata_order = metadata_stack_order(&ws); - assert_eq!(before_display_order, before_metadata_order); + // Display order is the workspace commit's parent array; the fixture's declared metadata + // order lags behind it until the next metadata write. + assert_eq!( + before_display_order, + ["refs/heads/B", "refs/heads/A"].map(str::to_owned) + ); // Move empty B on top of non-empty A. // This path rewrites metadata and keeps display + metadata aligned. - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let but_workspace::branch::move_branch::Outcome { rebase, ws_meta, .. } = but_workspace::branch::move_branch( editor, + &ws, "refs/heads/B".try_into()?, "refs/heads/A".try_into()?, )?; @@ -955,23 +970,25 @@ fn empty_move_keeps_display_order_aligned_with_metadata() -> anyhow::Result<()> .map(|ws_meta| workspace_metadata_stack_order(ws_meta, StackKind::AppliedAndUnapplied)) .unwrap_or_default(); - rebase.materialize()?; + let materialized = rebase.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; set_workspace_metadata(&mut meta, &ws, ws_meta)?; - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; let after_display_order = stack_display_order(&ws); + // The move changes both the stored metadata order and the displayed order; the + // displayed order is taken from the workspace-commit parent array (see snapshot). assert_ne!(updated_metadata_order, before_metadata_order); assert_ne!(after_display_order, before_display_order); - assert_eq!(after_display_order, updated_metadata_order); snapbox::assert_data_eq!( format!("{before_display_order:#?}"), snapbox::str![[r#" [ - "refs/heads/A", "refs/heads/B", + "refs/heads/A", ] "#]] ); @@ -993,7 +1010,7 @@ fn move_branch_when_base_segment_has_no_ref_name() -> anyhow::Result<()> { // When origin/main advances past the fork point, the old fork commit becomes // an unnamed base segment. Moving a branch should still work by falling back // to selecting by the segment's tip commit. - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-ws-commit-two-stacks-advanced-remote", |meta| { @@ -1017,7 +1034,6 @@ fn move_branch_when_base_segment_has_no_ref_name() -> anyhow::Result<()> { .raw() ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1032,19 +1048,21 @@ fn move_branch_when_base_segment_has_no_ref_name() -> anyhow::Result<()> { "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; // Move B on top of A โ€” the base segment at the old fork point has no ref name. let but_workspace::branch::move_branch::Outcome { rebase, ws_meta, .. } = but_workspace::branch::move_branch( editor, + &ws, "refs/heads/B".try_into()?, "refs/heads/A".try_into()?, )?; - rebase.materialize()?; + let materialized = rebase.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; set_workspace_metadata(&mut meta, &ws, ws_meta)?; - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; snapbox::assert_data_eq!( @@ -1082,7 +1100,7 @@ fn move_empty_branch_onto_non_empty_branch_with_advanced_target() -> anyhow::Res // reference node sitting above the base commit. Selecting the base by commit would point one // hop too far and fail the direct-parent check. Moving the empty branch onto the non-empty one // must still succeed. - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-with-empty-stack-target-advanced", |meta| { @@ -1105,33 +1123,34 @@ fn move_empty_branch_onto_non_empty_branch_with_advanced_target() -> anyhow::Res .raw() ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} - โ””โ”€โ”€ ๐Ÿ“™:B +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; // Put empty B on top of non-empty A. let but_workspace::branch::move_branch::Outcome { rebase, ws_meta, .. } = but_workspace::branch::move_branch( editor, + &ws, "refs/heads/B".try_into()?, "refs/heads/A".try_into()?, )?; - rebase.materialize()?; + let materialized = rebase.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; set_workspace_metadata(&mut meta, &ws, ws_meta)?; - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; snapbox::assert_data_eq!( @@ -1164,7 +1183,7 @@ fn move_empty_branch_onto_non_empty_branch_with_advanced_target() -> anyhow::Res fn move_non_empty_branch_onto_empty_branch_with_advanced_target() -> anyhow::Result<()> { // Same setup as the empty-onto-non-empty regression, but the subject is the non-empty branch // and the target is the empty one. Both directions must succeed when the target is ahead. - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-with-empty-stack-target-advanced", |meta| { @@ -1187,33 +1206,34 @@ fn move_non_empty_branch_onto_empty_branch_with_advanced_target() -> anyhow::Res .raw() ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} - โ””โ”€โ”€ ๐Ÿ“™:B +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; // Put non-empty A on top of empty B. let but_workspace::branch::move_branch::Outcome { rebase, ws_meta, .. } = but_workspace::branch::move_branch( editor, + &ws, "refs/heads/A".try_into()?, "refs/heads/B".try_into()?, )?; - rebase.materialize()?; + let materialized = rebase.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; set_workspace_metadata(&mut meta, &ws, ws_meta)?; - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; snapbox::assert_data_eq!( @@ -1232,9 +1252,8 @@ fn move_non_empty_branch_onto_empty_branch_with_advanced_target() -> anyhow::Res snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 85efbe4 โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:A - โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:B + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] ); @@ -1276,7 +1295,7 @@ fn set_workspace_metadata( if let Some((ws_meta, ref_name)) = ws_meta.zip(ws.ref_name()) { let mut md = meta.workspace(ref_name)?; *md = ws_meta; - md.set_project_meta(ws.graph.project_meta.clone()); + md.set_project_meta(ws.project_meta().clone()); meta.set_workspace(&md)?; } Ok(()) @@ -1290,7 +1309,7 @@ mod single_branch_mode { use but_core::RefMetadata; use but_core::ref_metadata::StackId; - use but_graph::init::Options; + use but_graph::walk::Options; use but_meta::BranchOrderMetadata; use but_rebase::graph_rebase::Editor; use but_testsupport::{graph_workspace, invoke_bash}; @@ -1359,16 +1378,15 @@ mod single_branch_mode { subject: &gix::refs::FullNameRef, target: &gix::refs::FullNameRef, ) -> anyhow::Result>> { - let mut ws = but_graph::Graph::from_head(repo, meta, project_meta, Options::limited())? - .into_workspace()?; - let editor = Editor::create(&mut ws, meta, repo)?; + let ws = but_graph::Workspace::from_head(repo, meta, project_meta, Options::limited())?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), meta, repo)?; let but_workspace::branch::move_branch::Outcome { rebase, ws_meta, new_tip, branch_stack_order, .. - } = but_workspace::branch::move_branch(editor, subject, target)?; + } = but_workspace::branch::move_branch(editor, &ws, subject, target)?; assert!( ws_meta.is_none(), "ad-hoc reorder lives in branch_order, not workspace metadata" @@ -1457,9 +1475,12 @@ mod single_branch_mode { let mut meta = branch_order_meta(&repo)?; let main_ref = r("refs/heads/main"); - let mut ws = - but_graph::Graph::from_head(&repo, &meta, project_meta.clone(), Options::limited())? - .into_workspace()?; + let mut ws = but_graph::Workspace::from_head( + &repo, + &meta, + project_meta.clone(), + Options::limited(), + )?; // Each branch is inserted directly below `main`, so creating them in this order yields the // chain [main, empty-top, empty-bottom, base] (tip to base). @@ -1490,19 +1511,23 @@ mod single_branch_mode { let (_tmp, repo, mut meta, project_meta) = ad_hoc_workspace_with_two_empty_branches()?; let main_ref = r("refs/heads/main"); - let mut ws = but_graph::Graph::from_head(&repo, &meta, project_meta, Options::limited())? - .into_workspace()?; + let ws = but_graph::Workspace::from_head(&repo, &meta, project_meta, Options::limited())?; // `main` is the checked-out entrypoint (the projected tip). assert_eq!(ws.ref_name(), Some(main_ref)); // Move empty `empty-bottom` on top of the checked-out `main`, which makes it the new tip. - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let but_workspace::branch::move_branch::Outcome { rebase, new_tip, branch_stack_order, .. - } = but_workspace::branch::move_branch(editor, r("refs/heads/empty-bottom"), main_ref)?; + } = but_workspace::branch::move_branch( + editor, + &ws, + r("refs/heads/empty-bottom"), + main_ref, + )?; rebase.materialize()?; // The subject is reported as the new tip so the caller can check it out. @@ -1528,10 +1553,9 @@ mod single_branch_mode { fn reorder_below_tip_has_no_new_tip() -> anyhow::Result<()> { let (_tmp, repo, mut meta, project_meta) = ad_hoc_workspace_with_two_empty_branches()?; - let mut ws = but_graph::Graph::from_head(&repo, &meta, project_meta, Options::limited())? - .into_workspace()?; + let ws = but_graph::Workspace::from_head(&repo, &meta, project_meta, Options::limited())?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let but_workspace::branch::move_branch::Outcome { rebase, new_tip, @@ -1539,6 +1563,7 @@ mod single_branch_mode { .. } = but_workspace::branch::move_branch( editor, + &ws, r("refs/heads/empty-bottom"), r("refs/heads/empty-top"), )?; @@ -1562,20 +1587,23 @@ mod single_branch_mode { let (_tmp, repo, mut meta, project_meta) = ad_hoc_workspace_with_two_empty_branches()?; let main_ref = r("refs/heads/main"); - let mut ws = - but_graph::Graph::from_head(&repo, &meta, project_meta.clone(), Options::limited())? - .into_workspace()?; + let ws = but_graph::Workspace::from_head( + &repo, + &meta, + project_meta.clone(), + Options::limited(), + )?; // Single-branch (ad-hoc) workspace: `HEAD` is on `main` directly, no `gitbutler/workspace` // commit. `empty-top`/`empty-bottom` are empty segments; `base` owns the commits. snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:1:main[๐ŸŒณ] <> โœ“! on 281da94 -โ””โ”€โ”€ โ‰ก:1:main[๐ŸŒณ] {1} - โ”œโ”€โ”€ :1:main[๐ŸŒณ] - โ”œโ”€โ”€ ๐Ÿ“™:2:empty-top - โ”œโ”€โ”€ ๐Ÿ“™:3:empty-bottom - โ””โ”€โ”€ ๐Ÿ“™:0:base +โŒ‚:main[๐ŸŒณ] <> โœ“! on 281da94 +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] {1} + โ”œโ”€โ”€ :main[๐ŸŒณ] + โ”œโ”€โ”€ ๐Ÿ“™:empty-top + โ”œโ”€โ”€ ๐Ÿ“™:empty-bottom + โ””โ”€โ”€ ๐Ÿ“™:base โ”œโ”€โ”€ ยท281da94 โ”œโ”€โ”€ ยท12995d7 โ””โ”€โ”€ ยท3d57fc1 @@ -1593,7 +1621,7 @@ mod single_branch_mode { ); // Move `empty-bottom` on top of `empty-top` (both empty) - a pure metadata reorder. - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let but_workspace::branch::move_branch::Outcome { rebase, ws_meta, @@ -1601,6 +1629,7 @@ mod single_branch_mode { .. } = but_workspace::branch::move_branch( editor, + &ws, r("refs/heads/empty-bottom"), r("refs/heads/empty-top"), )?; @@ -1624,17 +1653,16 @@ mod single_branch_mode { ); // Re-projecting from the reloaded metadata reflects the new order, and no commit was moved. - let ws = but_graph::Graph::from_head(&repo, &meta, project_meta, Options::limited())? - .into_workspace()?; + let ws = but_graph::Workspace::from_head(&repo, &meta, project_meta, Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:1:main[๐ŸŒณ] <> โœ“! on 281da94 -โ””โ”€โ”€ โ‰ก:1:main[๐ŸŒณ] {1} - โ”œโ”€โ”€ :1:main[๐ŸŒณ] - โ”œโ”€โ”€ ๐Ÿ“™:2:empty-bottom - โ”œโ”€โ”€ ๐Ÿ“™:3:empty-top - โ””โ”€โ”€ ๐Ÿ“™:0:base +โŒ‚:main[๐ŸŒณ] <> โœ“! on 281da94 +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] {1} + โ”œโ”€โ”€ :main[๐ŸŒณ] + โ”œโ”€โ”€ ๐Ÿ“™:empty-bottom + โ”œโ”€โ”€ ๐Ÿ“™:empty-top + โ””โ”€โ”€ ๐Ÿ“™:base โ”œโ”€โ”€ ยท281da94 โ”œโ”€โ”€ ยท12995d7 โ””โ”€โ”€ ยท3d57fc1 @@ -1645,6 +1673,85 @@ mod single_branch_mode { Ok(()) } + /// Repro of `moveBranch.spec.ts` "keeps empty dependent branches when moving their + /// commit-owning branch to the top": a commit-owning branch with two empty branches on its + /// tip, moved to the top. The commit must ride up and the empties must drop to the base. + #[test] + fn move_commit_owning_branch_to_top_past_empties() -> anyhow::Result<()> { + let (_tmp, repo, legacy_meta) = + named_writable_scenario("single-branch-commit-with-empties")?; + let project_meta = project_meta(&legacy_meta); + let mut meta = branch_order_meta(&repo)?; + meta.set_branch_stack_order(&[ + r("refs/heads/empty-top").to_owned(), + r("refs/heads/empty-low").to_owned(), + r("refs/heads/commit-branch").to_owned(), + r("refs/heads/single-branch-fixture").to_owned(), + ])?; + + let ws = but_graph::Workspace::from_head( + &repo, + &meta, + project_meta.clone(), + Options::limited(), + )?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +โŒ‚:empty-top[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:empty-top[๐ŸŒณ] {1} + โ”œโ”€โ”€ :empty-top[๐ŸŒณ] + โ”œโ”€โ”€ :empty-low + โ”œโ”€โ”€ :commit-branch + โ”‚ โ””โ”€โ”€ ยทcfb7163 + โ””โ”€โ”€ :single-branch-fixture + โ””โ”€โ”€ ยท563a7fc + +"#]] + ); + + let branch_stack_order = move_branch_and_apply( + &repo, + &mut meta, + project_meta.clone(), + r("refs/heads/commit-branch"), + r("refs/heads/empty-top"), + )?; + assert_eq!( + branch_stack_order, + Some(vec![ + r("refs/heads/commit-branch").to_owned(), + r("refs/heads/empty-top").to_owned(), + r("refs/heads/empty-low").to_owned(), + r("refs/heads/single-branch-fixture").to_owned(), + ]), + "commit-branch reorders to the top; empties keep their relative order below it" + ); + + // The empties re-point onto the base on disk; commit-branch keeps its commit. + let base_tip = branch_tip(&repo, "single-branch-fixture"); + assert_eq!(branch_tip(&repo, "empty-top"), base_tip); + assert_eq!(branch_tip(&repo, "empty-low"), base_tip); + assert_ne!(branch_tip(&repo, "commit-branch"), base_tip); + + let ws = but_graph::Workspace::from_head(&repo, &meta, project_meta, Options::limited())?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +โŒ‚:commit-branch[๐ŸŒณ] <> โœ“! +โ””โ”€โ”€ โ‰ก:commit-branch[๐ŸŒณ] {1} + โ”œโ”€โ”€ :commit-branch[๐ŸŒณ] + โ”‚ โ””โ”€โ”€ ยทcfb7163 + โ”œโ”€โ”€ :empty-top + โ”œโ”€โ”€ :empty-low + โ””โ”€โ”€ :single-branch-fixture + โ””โ”€โ”€ ยท563a7fc + +"#]] + ); + Ok(()) + } + #[test] fn move_middle_non_empty_branch_to_top_checks_out_subject() -> anyhow::Result<()> { let (_tmp, repo, mut meta, project_meta) = @@ -1868,12 +1975,11 @@ mod single_branch_mode { fn reorder_empty_branch_onto_commit_owning_base() -> anyhow::Result<()> { let (_tmp, repo, mut meta, project_meta) = ad_hoc_workspace_with_two_empty_branches()?; - let mut ws = but_graph::Graph::from_head(&repo, &meta, project_meta, Options::limited())? - .into_workspace()?; + let ws = but_graph::Workspace::from_head(&repo, &meta, project_meta, Options::limited())?; // `base` owns the stack's commits; moving the empty `empty-top` on top of it is still just a // metadata reorder and must succeed (previously rejected because the target owns commits). - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let but_workspace::branch::move_branch::Outcome { rebase, new_tip, @@ -1881,6 +1987,7 @@ mod single_branch_mode { .. } = but_workspace::branch::move_branch( editor, + &ws, r("refs/heads/empty-top"), r("refs/heads/base"), )?; @@ -1920,17 +2027,16 @@ mod single_branch_mode { repo.reference(r("refs/heads/x"), tip, PreviousValue::Any, "test")?; repo.reference(r("refs/heads/y"), tip, PreviousValue::Any, "test")?; - let mut ws = but_graph::Graph::from_head(&repo, &meta, project_meta, Options::limited())? - .into_workspace()?; + let ws = but_graph::Workspace::from_head(&repo, &meta, project_meta, Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -โŒ‚:1:main[๐ŸŒณ] <> โœ“! on 281da94 -โ””โ”€โ”€ โ‰ก:1:main[๐ŸŒณ] {1} - โ”œโ”€โ”€ :1:main[๐ŸŒณ] - โ”œโ”€โ”€ ๐Ÿ“™:2:empty-top - โ”œโ”€โ”€ ๐Ÿ“™:3:empty-bottom - โ””โ”€โ”€ ๐Ÿ“™:0:base +โŒ‚:main[๐ŸŒณ] <> โœ“! on 281da94 +โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] {1} + โ”œโ”€โ”€ :main[๐ŸŒณ] + โ”œโ”€โ”€ ๐Ÿ“™:empty-top + โ”œโ”€โ”€ ๐Ÿ“™:empty-bottom + โ””โ”€โ”€ ๐Ÿ“™:base โ”œโ”€โ”€ ยท281da94 โ–บx, โ–บy โ”œโ”€โ”€ ยท12995d7 โ””โ”€โ”€ ยท3d57fc1 @@ -1938,9 +2044,10 @@ mod single_branch_mode { "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let err = match but_workspace::branch::move_branch( editor, + &ws, r("refs/heads/x"), r("refs/heads/y"), ) { @@ -1967,15 +2074,15 @@ mod single_branch_mode { let main_ref = r("refs/heads/main"); let order_before = meta.branch_stack_order(main_ref)?; - let mut ws = but_graph::Graph::from_head(&repo, &meta, project_meta, Options::limited())? - .into_workspace()?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let ws = but_graph::Workspace::from_head(&repo, &meta, project_meta, Options::limited())?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let but_workspace::branch::move_branch::Outcome { rebase, branch_stack_order, .. } = but_workspace::branch::move_branch( editor, + &ws, r("refs/heads/empty-bottom"), r("refs/heads/empty-top"), )?; diff --git a/crates/but-workspace/tests/workspace/branch/remove_reference.rs b/crates/but-workspace/tests/workspace/branch/remove_reference.rs index 64f9286d981..87190f14299 100644 --- a/crates/but-workspace/tests/workspace/branch/remove_reference.rs +++ b/crates/but-workspace/tests/workspace/branch/remove_reference.rs @@ -14,7 +14,7 @@ use crate::{ #[test] fn no_errors_due_to_idempotency_in_empty_workspace() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, desc) = + let (_tmp, ws, repo, mut meta, desc) = named_writable_scenario_with_args_and_description_and_graph( "single-branch-no-ws-commit-no-target", ["A", "B"], @@ -35,7 +35,6 @@ Single commit, no main remote/target, no ws commit, but ws-reference "#]] ); - let ws = graph.into_workspace()?; // the workspace is empty. snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -83,7 +82,7 @@ Single commit, no main remote/target, no ws commit, but ws-reference "#]] ); - let ws = ws.graph.into_workspace_of_redone_traversal(&repo, &meta)?; + let ws = ws.redo(&repo, &meta, Default::default())?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -97,7 +96,7 @@ Single commit, no main remote/target, no ws commit, but ws-reference #[test] fn journey_single_branch_no_ws_commit_no_target() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, desc) = named_writable_scenario_with_description_and_graph( + let (_tmp, mut ws, repo, mut meta, desc) = named_writable_scenario_with_description_and_graph( "single-branch-3-commits-no-ws-commit-more-branches", |meta| { add_stack_with_segments(meta, 0, "A", StackState::InWorkspace, &[]); @@ -120,7 +119,6 @@ Single commit, target, no ws commit, but ws-reference and a named segment, and b "#]] ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -151,7 +149,7 @@ Single commit, target, no ws commit, but ws-reference and a named segment, and b .expect("we deleted something"); } - let ws = ws.graph.into_workspace_of_redone_traversal(&repo, &meta)?; + let ws = ws.redo(&repo, &meta, Default::default())?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -169,7 +167,7 @@ Single commit, target, no ws commit, but ws-reference and a named segment, and b #[test] fn journey_single_branch_ws_commit_no_target() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, desc) = named_writable_scenario_with_description_and_graph( + let (_tmp, mut ws, repo, mut meta, desc) = named_writable_scenario_with_description_and_graph( "single-branch-4-commits-more-branches", |meta| { add_stack_with_segments( @@ -200,7 +198,6 @@ Two commits in main, target setup, ws commit, many more usable branches "#]] ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -298,7 +295,7 @@ Two commits in main, target setup, ws commit, many more usable branches #[test] fn journey_no_ws_commit_no_target() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, desc) = + let (_tmp, ws, repo, mut meta, desc) = named_writable_scenario_with_args_and_description_and_graph( "single-branch-no-ws-commit-no-target", ["A", "B", "C", "D", "E"], @@ -322,7 +319,6 @@ Single commit, no main remote/target, no ws commit, but ws-reference "#]] ); - let ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -373,7 +369,7 @@ Single commit, no main remote/target, no ws commit, but ws-reference "recreate ref to show metadata is present and unchanged", )?; - let ws = ws.graph.into_workspace_of_redone_traversal(&repo, &meta)?; + let ws = ws.redo(&repo, &meta, Default::default())?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -459,7 +455,7 @@ Single commit, no main remote/target, no ws commit, but ws-reference "#]] ); - let ws = ws.graph.into_workspace_of_redone_traversal(&repo, &meta)?; + let ws = ws.redo(&repo, &meta, Default::default())?; // The workspace is completely empty. snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), diff --git a/crates/but-workspace/tests/workspace/branch/tear_off_branch.rs b/crates/but-workspace/tests/workspace/branch/tear_off_branch.rs index c646de18a30..3baa499b2aa 100644 --- a/crates/but-workspace/tests/workspace/branch/tear_off_branch.rs +++ b/crates/but-workspace/tests/workspace/branch/tear_off_branch.rs @@ -1,7 +1,7 @@ use but_core::{RefMetadata, ref_metadata::StackId}; use but_rebase::graph_rebase::Editor; use but_testsupport::{graph_workspace, visualize_commit_graph_all}; -use snapbox::IntoData; +use snapbox::prelude::*; use crate::ref_info::with_workspace_commit::utils::{ StackState, add_stack_with_segments, named_writable_scenario_with_description_and_graph, @@ -9,7 +9,7 @@ use crate::ref_info::with_workspace_commit::utils::{ #[test] fn tear_off_top_most_branch() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-ws-commit-single-stack-double-stack", |meta| { @@ -32,29 +32,29 @@ fn tear_off_top_most_branch() -> anyhow::Result<()> { .raw() ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; // Tear off C from the stack. let but_workspace::branch::move_branch::Outcome { rebase, ws_meta, .. } = but_workspace::branch::tear_off_branch( editor, + &ws, "refs/heads/C".try_into()?, Some(StackId::from_number_for_testing(3)), )?; @@ -62,18 +62,18 @@ fn tear_off_top_most_branch() -> anyhow::Result<()> { // Materialize the operation rebase.materialize()?; set_workspace_metadata(&mut meta, &ws, ws_meta)?; - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -*-. efd284c (HEAD -> gitbutler/workspace) GitButler Workspace Commit +*-. 16e2eb1 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ \ -| | * 09d8e52 (A) A +| | * 8e00332 (C) C | * | c813d8d (B) B | |/ -* / 8e00332 (C) C +* / 09d8e52 (A) A |/ * 85efbe4 (origin/main, main) M @@ -103,7 +103,7 @@ fn tear_off_top_most_branch() -> anyhow::Result<()> { #[test] fn tear_off_bottom_most_branch() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-ws-commit-single-stack-double-stack", |meta| { @@ -126,29 +126,29 @@ fn tear_off_bottom_most_branch() -> anyhow::Result<()> { .raw() ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; // Tear off B from the stack. let but_workspace::branch::move_branch::Outcome { rebase, ws_meta, .. } = but_workspace::branch::tear_off_branch( editor, + &ws, "refs/heads/B".try_into()?, Some(StackId::from_number_for_testing(3)), )?; @@ -156,18 +156,18 @@ fn tear_off_bottom_most_branch() -> anyhow::Result<()> { // Materialize the operation rebase.materialize()?; set_workspace_metadata(&mut meta, &ws, ws_meta)?; - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -*-. a3c9e85 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +*-. 7e46497 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ \ -| | * 09d8e52 (A) A -| * | 8e00332 (C) C +| | * c813d8d (B) B +| * | 09d8e52 (A) A | |/ -* / c813d8d (B) B +* / 8e00332 (C) C |/ * 85efbe4 (origin/main, main) M @@ -179,12 +179,12 @@ fn tear_off_bottom_most_branch() -> anyhow::Result<()> { graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} โ”‚ โ””โ”€โ”€ ๐Ÿ“™:C โ”‚ โ””โ”€โ”€ ยท8e00332 (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {3} โ””โ”€โ”€ ๐Ÿ“™:B โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) @@ -197,7 +197,7 @@ fn tear_off_bottom_most_branch() -> anyhow::Result<()> { #[test] fn tear_off_only_branch_in_stack() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-ws-commit-single-stack-double-stack", |meta| { @@ -220,29 +220,29 @@ fn tear_off_only_branch_in_stack() -> anyhow::Result<()> { .raw() ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; // Tear off A from the stack. Should be a no-op. let but_workspace::branch::move_branch::Outcome { rebase, ws_meta, .. } = but_workspace::branch::tear_off_branch( editor, + &ws, "refs/heads/A".try_into()?, Some(StackId::from_number_for_testing(3)), )?; @@ -250,7 +250,7 @@ fn tear_off_only_branch_in_stack() -> anyhow::Result<()> { // Materialize the operation rebase.materialize()?; set_workspace_metadata(&mut meta, &ws, ws_meta)?; - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; snapbox::assert_data_eq!( @@ -272,14 +272,14 @@ fn tear_off_only_branch_in_stack() -> anyhow::Result<()> { graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] ); @@ -289,7 +289,7 @@ fn tear_off_only_branch_in_stack() -> anyhow::Result<()> { #[test] fn tear_off_from_single_stack_in_ws_top() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph("ws-ref-ws-commit-one-stack", |meta| { add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); add_stack_with_segments(meta, 2, "B", StackState::InWorkspace, &[]); @@ -305,7 +305,6 @@ fn tear_off_from_single_stack_in_ws_top() -> anyhow::Result<()> { "#]] ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -319,12 +318,13 @@ fn tear_off_from_single_stack_in_ws_top() -> anyhow::Result<()> { "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; // Tear off B from the stack. let but_workspace::branch::move_branch::Outcome { rebase, ws_meta, .. } = but_workspace::branch::tear_off_branch( editor, + &ws, "refs/heads/B".try_into()?, Some(StackId::from_number_for_testing(3)), )?; @@ -332,16 +332,16 @@ fn tear_off_from_single_stack_in_ws_top() -> anyhow::Result<()> { // Materialize the operation rebase.materialize()?; set_workspace_metadata(&mut meta, &ws, ws_meta)?; - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* e2d89a5 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 828af37 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -| * 09d8e52 (A) A -* | 1273ba9 (B) B +| * 1273ba9 (B) B +* | 09d8e52 (A) A |/ * 85efbe4 (origin/main, main) M @@ -368,7 +368,7 @@ fn tear_off_from_single_stack_in_ws_top() -> anyhow::Result<()> { #[test] fn tear_off_from_single_stack_in_ws_bottom() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph("ws-ref-ws-commit-one-stack", |meta| { add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); add_stack_with_segments(meta, 2, "B", StackState::InWorkspace, &[]); @@ -384,7 +384,6 @@ fn tear_off_from_single_stack_in_ws_bottom() -> anyhow::Result<()> { "#]] ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -398,12 +397,13 @@ fn tear_off_from_single_stack_in_ws_bottom() -> anyhow::Result<()> { "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; // Tear off A from the stack. let but_workspace::branch::move_branch::Outcome { rebase, ws_meta, .. } = but_workspace::branch::tear_off_branch( editor, + &ws, "refs/heads/A".try_into()?, Some(StackId::from_number_for_testing(3)), )?; @@ -411,16 +411,16 @@ fn tear_off_from_single_stack_in_ws_bottom() -> anyhow::Result<()> { // Materialize the operation rebase.materialize()?; set_workspace_metadata(&mut meta, &ws, ws_meta)?; - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* 828af37 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* e2d89a5 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -| * 1273ba9 (B) B -* | 09d8e52 (A) A +| * 09d8e52 (A) A +* | 1273ba9 (B) B |/ * 85efbe4 (origin/main, main) M @@ -447,7 +447,7 @@ fn tear_off_from_single_stack_in_ws_bottom() -> anyhow::Result<()> { #[test] fn tear_off_empty_branch() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-ws-commit-one-stack-with-empty-top-branch", |meta| { @@ -464,7 +464,6 @@ fn tear_off_empty_branch() -> anyhow::Result<()> { "#]] ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -477,12 +476,13 @@ fn tear_off_empty_branch() -> anyhow::Result<()> { "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; // Tear off B from the stack. let but_workspace::branch::move_branch::Outcome { rebase, ws_meta, .. } = but_workspace::branch::tear_off_branch( editor, + &ws, "refs/heads/B".try_into()?, Some(StackId::from_number_for_testing(3)), )?; @@ -490,15 +490,15 @@ fn tear_off_empty_branch() -> anyhow::Result<()> { // Materialize the operation rebase.materialize()?; set_workspace_metadata(&mut meta, &ws, ws_meta)?; - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* d744692 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* b1314f4 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -| * 09d8e52 (A) A +* | 09d8e52 (A) A |/ * 85efbe4 (origin/main, main, B) M @@ -524,7 +524,7 @@ fn tear_off_empty_branch() -> anyhow::Result<()> { #[test] fn tear_off_non_empty_branch() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-ws-commit-one-stack-with-empty-top-branch", |meta| { @@ -541,7 +541,6 @@ fn tear_off_non_empty_branch() -> anyhow::Result<()> { "#]] ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -554,12 +553,13 @@ fn tear_off_non_empty_branch() -> anyhow::Result<()> { "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; // Tear off A from the stack. let but_workspace::branch::move_branch::Outcome { rebase, ws_meta, .. } = but_workspace::branch::tear_off_branch( editor, + &ws, "refs/heads/A".try_into()?, Some(StackId::from_number_for_testing(3)), )?; @@ -567,15 +567,15 @@ fn tear_off_non_empty_branch() -> anyhow::Result<()> { // Materialize the operation rebase.materialize()?; set_workspace_metadata(&mut meta, &ws, ws_meta)?; - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* b1314f4 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* d744692 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -* | 09d8e52 (A) A +| * 09d8e52 (A) A |/ * 85efbe4 (origin/main, main, B) M diff --git a/crates/but-workspace/tests/workspace/commit/commit_amend.rs b/crates/but-workspace/tests/workspace/commit/commit_amend.rs index 392f2091a65..4efed527723 100644 --- a/crates/but-workspace/tests/workspace/commit/commit_amend.rs +++ b/crates/but-workspace/tests/workspace/commit/commit_amend.rs @@ -40,7 +40,7 @@ fn worktree_changes_as_specs_with_hunks( #[test] fn amend_commit_smoke_test() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, ws, repo, mut _meta, _description) = writable_scenario("reword-three-commits", |_| {})?; let two_id = repo.rev_parse_single("two")?.detach(); std::fs::write( @@ -48,8 +48,7 @@ fn amend_commit_smoke_test() -> Result<()> { "amended\n", )?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; let outcome = commit_amend(editor, two_id, worktree_changes_as_specs(&repo)?, 0)?; assert!(outcome.rejected_specs.is_empty()); @@ -79,7 +78,7 @@ fn amend_commit_smoke_test() -> Result<()> { /// but there should be no remaining uncommitted changes. #[test] fn amend_into_earlier_commit_leaves_no_uncommitted_changes() -> Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = writable_scenario("amend-with-partial-commit", |_| {})?; // Find the "save 1" commit (first commit on the stack, parent of "partial 1") @@ -99,8 +98,7 @@ fn amend_into_earlier_commit_leaves_no_uncommitted_changes() -> Result<()> { ); let context_lines = 0; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let outcome = commit_amend( editor, save_1_id, @@ -135,7 +133,7 @@ fn amend_into_earlier_commit_leaves_no_uncommitted_changes() -> Result<()> { /// After amend, b-file.txt must still appear as a deleted uncommitted change. #[test] fn amend_with_two_stacks_preserves_uncommitted_deletions() -> Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = writable_scenario("amend-two-stacks-with-deletions", |meta| { add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); add_stack_with_segments(meta, 2, "B", StackState::InWorkspace, &[]); @@ -175,8 +173,7 @@ fn amend_with_two_stacks_preserves_uncommitted_deletions() -> Result<()> { // Find the commit on branch A let a_commit_id = repo.rev_parse_single("A")?.detach(); - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let outcome = commit_amend(editor, a_commit_id, a_file_specs, 0)?; assert!(outcome.rejected_specs.is_empty()); diff --git a/crates/but-workspace/tests/workspace/commit/commit_create.rs b/crates/but-workspace/tests/workspace/commit/commit_create.rs index 60617b655bc..945e862d2cc 100644 --- a/crates/but-workspace/tests/workspace/commit/commit_create.rs +++ b/crates/but-workspace/tests/workspace/commit/commit_create.rs @@ -1,8 +1,7 @@ use anyhow::Result; use but_core::DiffSpec; use but_rebase::graph_rebase::{ - Editor, LookupStep as _, - mutate::{InsertSide, RelativeToRef}, + Editor, LookupStep as _, mutate::InsertSide, selector::RelativeToRef, }; use but_workspace::commit::commit_create; @@ -18,7 +17,7 @@ fn worktree_changes_as_specs(repo: &gix::Repository) -> Result> { #[test] fn commit_above_commit() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, ws, repo, mut _meta, _description) = writable_scenario("reword-three-commits", |_| {})?; let two_id = repo.rev_parse_single("two")?.detach(); std::fs::write( @@ -27,8 +26,7 @@ fn commit_above_commit() -> Result<()> { "inserted\n", )?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; let outcome = commit_create( editor, worktree_changes_as_specs(&repo)?, @@ -64,7 +62,7 @@ fn commit_above_commit() -> Result<()> { #[test] fn commit_below_commit() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, ws, repo, mut _meta, _description) = writable_scenario("reword-three-commits", |_| {})?; let one_id = repo.rev_parse_single("one")?.detach(); let two_id = repo.rev_parse_single("two")?.detach(); @@ -74,8 +72,7 @@ fn commit_below_commit() -> Result<()> { "inserted\n", )?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; let outcome = commit_create( editor, worktree_changes_as_specs(&repo)?, @@ -105,7 +102,7 @@ fn commit_below_commit() -> Result<()> { #[test] fn commit_above_reference() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, ws, repo, mut _meta, _description) = writable_scenario("reword-three-commits", |_| {})?; let two_id = repo.rev_parse_single("two")?.detach(); let reference = repo.find_reference("two")?; @@ -115,8 +112,7 @@ fn commit_above_reference() -> Result<()> { "inserted\n", )?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; let outcome = commit_create( editor, worktree_changes_as_specs(&repo)?, @@ -152,7 +148,7 @@ fn commit_above_reference() -> Result<()> { #[test] fn commit_below_merge_commit_uses_first_parent() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, ws, repo, mut _meta, _description) = writable_scenario("merge-with-two-branches-line-offset", |_| {})?; let merge_id = repo.rev_parse_single("HEAD")?.detach(); let merge_commit = repo.find_commit(merge_id)?; @@ -167,8 +163,7 @@ fn commit_below_merge_commit_uses_first_parent() -> Result<()> { "inserted\n", )?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; let outcome = commit_create( editor, worktree_changes_as_specs(&repo)?, @@ -202,11 +197,10 @@ fn commit_below_merge_commit_uses_first_parent() -> Result<()> { #[test] fn commit_all_rejected_is_noop() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, ws, repo, mut _meta, _description) = writable_scenario("reword-three-commits", |_| {})?; let two_id = repo.rev_parse_single("two")?.detach(); - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; let outcome = commit_create( editor, diff --git a/crates/but-workspace/tests/workspace/commit/discard_commit.rs b/crates/but-workspace/tests/workspace/commit/discard_commit.rs index 01dd7d2e927..510de49307a 100644 --- a/crates/but-workspace/tests/workspace/commit/discard_commit.rs +++ b/crates/but-workspace/tests/workspace/commit/discard_commit.rs @@ -2,7 +2,7 @@ use anyhow::Result; use but_rebase::graph_rebase::Editor; use but_testsupport::{graph_workspace, visualize_commit_graph_all}; use but_workspace::commit::discard_commits; -use snapbox::IntoData; +use snapbox::prelude::*; use crate::ref_info::with_workspace_commit::utils::{ StackState, add_stack_with_segments, named_writable_scenario_with_description_and_graph, @@ -10,7 +10,7 @@ use crate::ref_info::with_workspace_commit::utils::{ #[test] fn discard_middle_commit_in_non_managed_workspace() -> Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph("reword-three-commits", |_| {})?; snapbox::assert_data_eq!( @@ -27,8 +27,7 @@ fn discard_middle_commit_in_non_managed_workspace() -> Result<()> { let two = repo.rev_parse_single("two")?; let three = repo.rev_parse_single("three")?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let outcome = discard_commits(editor, [two.detach()])?; outcome.materialize()?; @@ -73,7 +72,7 @@ fn discard_middle_commit_in_non_managed_workspace() -> Result<()> { #[test] fn discard_tip_commit_in_workspace_stack() -> Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-ws-commit-single-stack-double-stack", |meta| { @@ -100,37 +99,37 @@ fn discard_tip_commit_in_workspace_stack() -> Result<()> { let b = repo.rev_parse_single("B")?; let c = repo.rev_parse_single("C")?; - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let outcome = discard_commits(editor, [c.detach()])?; let outcome = outcome.materialize()?; + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; snapbox::assert_data_eq!( - graph_workspace(outcome.workspace).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] ); @@ -157,7 +156,7 @@ fn discard_tip_commit_in_workspace_stack() -> Result<()> { #[test] fn discard_bottom_commit_in_workspace_stack() -> Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-ws-commit-single-stack-double-stack", |meta| { @@ -185,37 +184,37 @@ fn discard_bottom_commit_in_workspace_stack() -> Result<()> { let c = repo.rev_parse_single("C")?; let main = repo.rev_parse_single("main")?; - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let outcome = discard_commits(editor, [b.detach()])?; let outcome = outcome.materialize()?; + ws.refresh_from_commit_graph(outcome.arena().clone(), &repo, outcome.meta)?; snapbox::assert_data_eq!( - graph_workspace(outcome.workspace).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ”‚ โ””โ”€โ”€ ยท8e00332 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:B +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”‚ โ””โ”€โ”€ ยท8e00332 (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] ); @@ -254,7 +253,7 @@ fn discard_bottom_commit_in_workspace_stack() -> Result<()> { #[test] fn can_discard_conflicted_commit() -> Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph("with-conflict", |_| {})?; snapbox::assert_data_eq!( @@ -268,8 +267,7 @@ fn can_discard_conflicted_commit() -> Result<()> { let conflicted = repo.rev_parse_single("conflicted")?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let outcome = discard_commits(editor, [conflicted.detach()])?; outcome.materialize()?; @@ -288,7 +286,7 @@ fn can_discard_conflicted_commit() -> Result<()> { #[test] fn discard_multiple_commits_in_single_rebase() -> Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph("reword-three-commits", |_| {})?; snapbox::assert_data_eq!( @@ -305,8 +303,7 @@ fn discard_multiple_commits_in_single_rebase() -> Result<()> { let two = repo.rev_parse_single("two")?; let three = repo.rev_parse_single("three")?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; // Discard both two and three in a single operation. let outcome = discard_commits(editor, [two.into(), three.into()])?; @@ -349,7 +346,7 @@ fn discard_multiple_commits_in_single_rebase() -> Result<()> { #[test] fn discard_both_commits_in_workspace_stack() -> Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-ws-commit-single-stack-double-stack", |meta| { @@ -377,8 +374,7 @@ fn discard_both_commits_in_workspace_stack() -> Result<()> { let c = repo.rev_parse_single("C")?; let main = repo.rev_parse_single("main")?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; // Discard both B and C in one rebase. let outcome = discard_commits(editor, [b.into(), c.into()])?; diff --git a/crates/but-workspace/tests/workspace/commit/insert_blank_commit.rs b/crates/but-workspace/tests/workspace/commit/insert_blank_commit.rs index db5445055ca..fb3785ff584 100644 --- a/crates/but-workspace/tests/workspace/commit/insert_blank_commit.rs +++ b/crates/but-workspace/tests/workspace/commit/insert_blank_commit.rs @@ -1,8 +1,5 @@ use anyhow::Result; -use but_rebase::graph_rebase::{ - Editor, - mutate::{InsertSide, RelativeToRef}, -}; +use but_rebase::graph_rebase::{Editor, mutate::InsertSide, selector::RelativeToRef}; use but_testsupport::visualize_commit_graph_all; use but_workspace::commit::insert_blank_commit; @@ -10,7 +7,7 @@ use crate::ref_info::with_workspace_commit::utils::named_writable_scenario_with_ #[test] fn insert_below_commit() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, ws, repo, mut _meta, _description) = writable_scenario("reword-three-commits", |_| {})?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -25,8 +22,7 @@ fn insert_below_commit() -> Result<()> { let head_tree = repo.head_tree_id()?; let id = repo.rev_parse_single("two")?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; insert_blank_commit( editor, InsertSide::Below, @@ -55,7 +51,7 @@ fn insert_below_commit() -> Result<()> { #[test] fn insert_above_commit() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, ws, repo, mut _meta, _description) = writable_scenario("reword-three-commits", |_| {})?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -70,8 +66,7 @@ fn insert_above_commit() -> Result<()> { let head_tree = repo.head_tree_id()?; let id = repo.rev_parse_single("two")?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; insert_blank_commit( editor, InsertSide::Above, @@ -98,7 +93,7 @@ fn insert_above_commit() -> Result<()> { #[test] fn insert_below_reference() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, ws, repo, mut _meta, _description) = writable_scenario("reword-three-commits", |_| {})?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -113,8 +108,7 @@ fn insert_below_reference() -> Result<()> { let head_tree = repo.head_tree_id()?; let reference = repo.find_reference("two")?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; insert_blank_commit( editor, InsertSide::Below, @@ -141,7 +135,7 @@ fn insert_below_reference() -> Result<()> { #[test] fn insert_above_reference() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, ws, repo, mut _meta, _description) = writable_scenario("reword-three-commits", |_| {})?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -156,8 +150,7 @@ fn insert_above_reference() -> Result<()> { let head_tree = repo.head_tree_id()?; let reference = repo.find_reference("two")?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; insert_blank_commit( editor, InsertSide::Above, diff --git a/crates/but-workspace/tests/workspace/commit/mod.rs b/crates/but-workspace/tests/workspace/commit/mod.rs index 9508a7f1da2..e0b6631cfed 100644 --- a/crates/but-workspace/tests/workspace/commit/mod.rs +++ b/crates/but-workspace/tests/workspace/commit/mod.rs @@ -11,9 +11,9 @@ mod uncommit_changes; mod from_new_merge_with_metadata { use bstr::ByteSlice; use but_core::ref_metadata::WorkspaceCommitRelation::Outside; - use but_graph::init::{Options, Overlay}; + use but_graph::walk::{Options, Overlay}; use but_testsupport::{visualize_commit_graph_all, visualize_tree}; - use but_workspace::{WorkspaceCommit, commit::merge::Tip}; + use but_workspace::{WorkspaceCommit, commit::merge::Seed}; use gix::{prelude::ObjectIdExt, refs::Target}; use snapbox::prelude::*; @@ -42,7 +42,7 @@ mod from_new_merge_with_metadata { let stacks = ["add-A"]; add_stacks(&mut meta, stacks); - let graph = but_graph::Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), @@ -51,7 +51,7 @@ mod from_new_merge_with_metadata { let out = WorkspaceCommit::from_new_merge_with_metadata( &to_stacks(stacks), None, - &graph, + &ws, &repo, None, )?; @@ -110,7 +110,7 @@ f53c910 let stacks = ["add-D", "add-A", "add-C", "add-B"]; add_stacks(&mut meta, stacks); - let graph = but_graph::Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), @@ -119,7 +119,7 @@ f53c910 let out = WorkspaceCommit::from_new_merge_with_metadata( &to_stacks(stacks), None, - &graph, + &ws, &repo, Some("refs/heads/has-no-effect-outside-conflicts".try_into()?), )?; @@ -214,7 +214,7 @@ https://docs.gitbutler.com/features/branch-management/integration-branch "#]] ); add_stacks(&mut meta, ["add-A", "add-B", "add-C"]); - let graph = but_graph::Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), @@ -222,13 +222,12 @@ https://docs.gitbutler.com/features/branch-management/integration-branch )?; let add_c_ref = "refs/heads/add-C".try_into()?; - let (segment, commit) = graph - .segment_and_commit_by_ref_name(add_c_ref) + let commit_id = ws + .commit_id_by_ref_name(add_c_ref) .expect("add-C is visible in the graph"); - let anon_c_tip = Tip { + let anon_c_tip = Seed { name: None, - commit_id: commit.id, - segment_idx: segment.id, + commit_id, }; let mut stacks = to_stacks(["add-A", "add-D", "add-B"]); @@ -240,7 +239,7 @@ https://docs.gitbutler.com/features/branch-management/integration-branch let out = WorkspaceCommit::from_new_merge_with_metadata( &stacks, [(2, anon_c_tip)], - &graph, + &ws, &repo, None, )?; @@ -299,7 +298,7 @@ Outcome { "clean-A", ]; add_stacks(&mut meta, stacks); - let graph = but_graph::Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), @@ -309,7 +308,7 @@ Outcome { let out = WorkspaceCommit::from_new_merge_with_metadata( &to_stacks(stacks), None, - &graph, + &ws, &repo, Some("refs/heads/conflict-hero".try_into()?), )?; @@ -363,7 +362,7 @@ Outcome { let out = WorkspaceCommit::from_new_merge_with_metadata( &to_stacks(stacks), None, - &graph, + &ws, &repo, None, )?; @@ -398,7 +397,7 @@ Outcome { #[test] fn with_conflict_commits() -> anyhow::Result<()> { - let (_tmp, mut graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph("with-conflict", |_| {})?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -431,7 +430,7 @@ Outcome { let stacks = ["tip-conflicted", "unrelated"]; add_stacks(&mut meta, stacks); - graph = graph.redo_traversal_with_overlay( + let ws = ws.redo( &repo, &meta, Overlay::default().with_references_if_new([ @@ -449,7 +448,7 @@ Outcome { let out = WorkspaceCommit::from_new_merge_with_metadata( &to_stacks(stacks), None, - &graph, + &ws, &repo, None, )?; @@ -512,7 +511,7 @@ Outcome { // NOTE: the caller would be expected to have prepared a graph that contains these branches. let stacks = ["clean-A", "conflict-C1", "clean-B", "conflict-C2"]; add_stacks(&mut meta, stacks); - let graph = but_graph::Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &*meta, but_core::ref_metadata::ProjectMeta::default(), @@ -522,7 +521,7 @@ Outcome { let out = WorkspaceCommit::from_new_merge_with_metadata( &to_stacks(stacks), None, - &graph, + &ws, &repo, None, )?; @@ -599,7 +598,7 @@ fc2bf71 let out = WorkspaceCommit::from_new_merge_with_metadata( &to_stacks(stacks), None, - &graph, + &ws, &repo, Some("refs/heads/conflict-C2".try_into()?), )?; @@ -676,7 +675,7 @@ https://docs.gitbutler.com/features/branch-management/integration-branch let out = WorkspaceCommit::from_new_merge_with_metadata( &to_stacks(["conflict-C2", "conflict-C2", "conflict-C1", "clean-A"]), None, - &graph, + &ws, &repo, Some("refs/heads/conflict-C1".try_into()?), )?; diff --git a/crates/but-workspace/tests/workspace/commit/move_changes.rs b/crates/but-workspace/tests/workspace/commit/move_changes.rs index 54d4e93488e..f6e7a3079e9 100644 --- a/crates/but-workspace/tests/workspace/commit/move_changes.rs +++ b/crates/but-workspace/tests/workspace/commit/move_changes.rs @@ -22,7 +22,7 @@ fn visualize_tree(id: gix::Id<'_>) -> String { #[test] fn move_changes_same_commit_is_noop() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, ws, repo, mut _meta, _description) = writable_scenario("reword-three-commits", |_| {})?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -35,8 +35,7 @@ fn move_changes_same_commit_is_noop() -> Result<()> { ); let commit_id = repo.rev_parse_single("three")?.detach(); - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; // Moving changes from a commit to itself should be a no-op let outcome = @@ -61,7 +60,7 @@ fn move_changes_same_commit_is_noop() -> Result<()> { #[test] fn move_file_from_head_to_parent() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, ws, repo, mut _meta, _description) = writable_scenario("reword-three-commits", |_| {})?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -103,8 +102,7 @@ aac5238 ); // Move three.txt from commit three to commit two - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; let outcome = move_changes_between_commits( editor, three_id, @@ -175,7 +173,7 @@ e0495e9 #[test] fn move_file_from_parent_to_head() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, ws, repo, mut _meta, _description) = writable_scenario("reword-three-commits", |_| {})?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -191,8 +189,7 @@ fn move_file_from_parent_to_head() -> Result<()> { let two_id = repo.rev_parse_single("two")?.detach(); // Move two.txt from commit two up to commit three - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; let outcome = move_changes_between_commits( editor, two_id, @@ -261,7 +258,7 @@ e0495e9 #[test] fn move_file_between_non_adjacent_commits() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, ws, repo, mut _meta, _description) = writable_scenario("reword-three-commits", |_| {})?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -277,8 +274,7 @@ fn move_file_between_non_adjacent_commits() -> Result<()> { let one_id = repo.rev_parse_single("one")?.detach(); // Move three.txt from commit three to commit one (skipping two) - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; let outcome = move_changes_between_commits( editor, three_id, @@ -364,15 +360,14 @@ e0495e9 #[test] fn error_when_changes_not_found_in_source() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, ws, repo, mut _meta, _description) = writable_scenario("reword-three-commits", |_| {})?; let three_id = repo.rev_parse_single("three")?.detach(); let two_id = repo.rev_parse_single("two")?.detach(); // Try to move a file that doesn't exist in source commit - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; let result = move_changes_between_commits( editor, three_id, diff --git a/crates/but-workspace/tests/workspace/commit/move_commit.rs b/crates/but-workspace/tests/workspace/commit/move_commit.rs index 22ffc1de3fe..d56637a9b34 100644 --- a/crates/but-workspace/tests/workspace/commit/move_commit.rs +++ b/crates/but-workspace/tests/workspace/commit/move_commit.rs @@ -1,7 +1,7 @@ use bstr::ByteSlice; use but_rebase::graph_rebase::{Editor, mutate::InsertSide}; use but_testsupport::{graph_workspace, visualize_commit_graph_all}; -use snapbox::IntoData; +use snapbox::prelude::*; use crate::ref_info::with_workspace_commit::utils::{ StackState, add_stack_with_segments, named_writable_scenario_with_description_and_graph, @@ -20,7 +20,7 @@ fn parent_subjects(repo: &gix::Repository, rev: &str) -> anyhow::Result anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-ws-commit-single-stack-double-stack", |meta| { @@ -43,24 +43,23 @@ fn move_top_commit_to_top_of_another_stack() -> anyhow::Result<()> { .raw() ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let a_commit = repo.rev_parse_single("A")?.detach(); let a_commit_selector = editor.select_commit(a_commit)?; let b_commit = repo.rev_parse_single("B")?.detach(); @@ -78,7 +77,7 @@ fn move_top_commit_to_top_of_another_stack() -> anyhow::Result<()> { // Materialize the operation let materialization = rebase.materialize()?; let commit_mapping = materialization.history.commit_mappings(); - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; let new_c_commit = commit_mapping.get(&c_commit); @@ -115,14 +114,14 @@ fn move_top_commit_to_top_of_another_stack() -> anyhow::Result<()> { graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ”œโ”€โ”€ ยทf2cc60d (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ”œโ”€โ”€ ยทf2cc60d (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] ); @@ -132,7 +131,7 @@ fn move_top_commit_to_top_of_another_stack() -> anyhow::Result<()> { #[test] fn move_bottom_commit_to_top_of_another_stack() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-ws-commit-single-stack-double-stack", |meta| { @@ -155,24 +154,23 @@ fn move_bottom_commit_to_top_of_another_stack() -> anyhow::Result<()> { .raw() ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let a_commit = repo.rev_parse_single("A")?.detach(); let a_commit_selector = editor.select_commit(a_commit)?; let b_commit = repo.rev_parse_single("B")?.detach(); @@ -190,7 +188,7 @@ fn move_bottom_commit_to_top_of_another_stack() -> anyhow::Result<()> { // Materialize the operation let materialization = rebase.materialize()?; let commit_mapping = materialization.history.commit_mappings(); - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; let new_b_commit = commit_mapping.get(&b_commit); @@ -229,14 +227,14 @@ fn move_bottom_commit_to_top_of_another_stack() -> anyhow::Result<()> { graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ”œโ”€โ”€ ยทf9061ed (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ”‚ โ””โ”€โ”€ ยท8e00332 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:B +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”‚ โ””โ”€โ”€ ยท8e00332 (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ”œโ”€โ”€ ยทf9061ed (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] ); @@ -246,7 +244,7 @@ fn move_bottom_commit_to_top_of_another_stack() -> anyhow::Result<()> { #[test] fn move_top_commit_to_bottom_of_another_stack() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-ws-commit-single-stack-double-stack", |meta| { @@ -269,24 +267,23 @@ fn move_top_commit_to_bottom_of_another_stack() -> anyhow::Result<()> { .raw() ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let a_commit = repo.rev_parse_single("A")?.detach(); let a_commit_selector = editor.select_commit(a_commit)?; let b_commit = repo.rev_parse_single("B")?.detach(); @@ -304,7 +301,7 @@ fn move_top_commit_to_bottom_of_another_stack() -> anyhow::Result<()> { // Materialize the operation let materialization = rebase.materialize()?; let commit_mapping = materialization.history.commit_mappings(); - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; let new_a_commit = commit_mapping.get(&a_commit); @@ -341,14 +338,14 @@ fn move_top_commit_to_bottom_of_another_stack() -> anyhow::Result<()> { graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ”œโ”€โ”€ ยท2506923 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ยท8e00332 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ”œโ”€โ”€ ยท2506923 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ยท8e00332 (๐Ÿ˜๏ธ) "#]] ); @@ -358,7 +355,7 @@ fn move_top_commit_to_bottom_of_another_stack() -> anyhow::Result<()> { #[test] fn move_bottom_commit_to_bottom_of_another_stack() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-ws-commit-single-stack-double-stack", |meta| { @@ -381,24 +378,23 @@ fn move_bottom_commit_to_bottom_of_another_stack() -> anyhow::Result<()> { .raw() ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let a_commit = repo.rev_parse_single("A")?.detach(); let a_commit_selector = editor.select_commit(a_commit)?; let b_commit = repo.rev_parse_single("B")?.detach(); @@ -416,7 +412,7 @@ fn move_bottom_commit_to_bottom_of_another_stack() -> anyhow::Result<()> { // Materialize the operation let materialization = rebase.materialize()?; let commit_mapping = materialization.history.commit_mappings(); - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; let new_a_commit = commit_mapping.get(&a_commit); @@ -455,14 +451,14 @@ fn move_bottom_commit_to_bottom_of_another_stack() -> anyhow::Result<()> { graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ”œโ”€โ”€ ยท4dfe841 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ”‚ โ””โ”€โ”€ ยท8e00332 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:B +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”‚ โ””โ”€โ”€ ยท8e00332 (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ”œโ”€โ”€ ยท4dfe841 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) "#]] ); @@ -472,7 +468,7 @@ fn move_bottom_commit_to_bottom_of_another_stack() -> anyhow::Result<()> { #[test] fn move_single_commit_to_the_top_of_another_branch() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-ws-commit-single-stack-double-stack", |meta| { @@ -495,24 +491,23 @@ fn move_single_commit_to_the_top_of_another_branch() -> anyhow::Result<()> { .raw() ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let a_commit = repo.rev_parse_single("A")?.detach(); let a_commit_selector = editor.select_commit(a_commit)?; let c_commit = repo.rev_parse_single("C")?.detach(); @@ -529,7 +524,7 @@ fn move_single_commit_to_the_top_of_another_branch() -> anyhow::Result<()> { // Materialize the operation let materialization = rebase.materialize()?; let commit_mapping = materialization.history.commit_mappings(); - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; let new_a_commit = commit_mapping.get(&a_commit); @@ -560,14 +555,14 @@ fn move_single_commit_to_the_top_of_another_branch() -> anyhow::Result<()> { graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ”‚ โ”œโ”€โ”€ ยท148f8f3 (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”‚ โ”œโ”€โ”€ ยท148f8f3 (๐Ÿ˜๏ธ) +โ”‚ โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A "#]] ); @@ -577,7 +572,7 @@ fn move_single_commit_to_the_top_of_another_branch() -> anyhow::Result<()> { #[test] fn move_single_commit_to_the_bottom_of_another_branch() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph( "ws-ref-ws-commit-single-stack-double-stack", |meta| { @@ -600,24 +595,23 @@ fn move_single_commit_to_the_bottom_of_another_branch() -> anyhow::Result<()> { .raw() ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”‚ โ””โ”€โ”€ ยท09bc93e (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยทc813d8d (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let a_commit = repo.rev_parse_single("A")?.detach(); let a_commit_selector = editor.select_commit(a_commit)?; let b_commit = repo.rev_parse_single("B")?.detach(); @@ -635,7 +629,7 @@ fn move_single_commit_to_the_bottom_of_another_branch() -> anyhow::Result<()> { // Materialize the operation let materialization = rebase.materialize()?; let commit_mapping = materialization.history.commit_mappings(); - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; let new_b_commit = commit_mapping.get(&b_commit); @@ -674,14 +668,14 @@ fn move_single_commit_to_the_bottom_of_another_branch() -> anyhow::Result<()> { graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ”‚ โ””โ”€โ”€ ยทad476a8 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:B - โ”œโ”€โ”€ ยทf9061ed (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 85efbe4 {2} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”‚ โ””โ”€โ”€ ยทad476a8 (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ”œโ”€โ”€ ยทf9061ed (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A "#]] ); @@ -691,7 +685,7 @@ fn move_single_commit_to_the_bottom_of_another_branch() -> anyhow::Result<()> { #[test] fn move_commit_to_empty_branch() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph("ws-with-empty-stack", |meta| { add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); add_stack_with_segments(meta, 2, "B", StackState::InWorkspace, &["B"]); @@ -709,21 +703,20 @@ fn move_commit_to_empty_branch() -> anyhow::Result<()> { .raw() ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} - โ””โ”€โ”€ ๐Ÿ“™:B +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let a_commit = repo.rev_parse_single("A")?.detach(); let a_commit_selector = editor.select_commit(a_commit)?; let b_ref_name = "refs/heads/B".try_into()?; @@ -739,7 +732,7 @@ fn move_commit_to_empty_branch() -> anyhow::Result<()> { // Materialize the operation rebase.materialize()?; - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; let tip_of_b_branch = repo.rev_parse_single("B")?.detach(); @@ -752,9 +745,9 @@ fn move_commit_to_empty_branch() -> anyhow::Result<()> { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* e16ce30 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 6d5c23e (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -* | 09d8e52 (B) A +| * 09d8e52 (B) A |/ * 85efbe4 (origin/main, main, A) M @@ -780,7 +773,7 @@ fn move_commit_to_empty_branch() -> anyhow::Result<()> { #[test] fn move_commit_in_non_managed_workspace() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph("reword-three-commits", |_| {})?; snapbox::assert_data_eq!( @@ -793,7 +786,6 @@ fn move_commit_in_non_managed_workspace() -> anyhow::Result<()> { "#]] ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -809,7 +801,7 @@ fn move_commit_in_non_managed_workspace() -> anyhow::Result<()> { "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let three_commit = repo.rev_parse_single("three")?.detach(); let three_commit_selector = editor.select_commit(three_commit)?; let two_ref_name = "refs/heads/two".try_into()?; @@ -825,7 +817,7 @@ fn move_commit_in_non_managed_workspace() -> anyhow::Result<()> { // Materialize the operation rebase.materialize()?; - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; let tip_of_three_branch = repo.rev_parse_single("three")?.detach(); @@ -857,10 +849,12 @@ fn move_commit_in_non_managed_workspace() -> anyhow::Result<()> { โŒ‚:three[๐ŸŒณ] <> โœ“! โ””โ”€โ”€ โ‰ก:three[๐ŸŒณ] {1} โ”œโ”€โ”€ :three[๐ŸŒณ] - โ”‚ โ”œโ”€โ”€ ยทc9f444c โ–บtwo - โ”‚ โ””โ”€โ”€ ยท16fd221 + โ”œโ”€โ”€ :two <> origin/twoโ‡ก1 + โ”‚ โ””โ”€โ”€ ยทc9f444c + โ”œโ”€โ”€ :origin/two + โ”‚ โ””โ”€โ”€ โ„16fd221 โ””โ”€โ”€ :one - โ””โ”€โ”€ ยท8b426d0 + โ””โ”€โ”€ โ„8b426d0 "#]] ); @@ -870,7 +864,7 @@ fn move_commit_in_non_managed_workspace() -> anyhow::Result<()> { #[test] fn reorder_merge_commit_above_keeps_child_commits_visible() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph("gb-1525-reorder-merge-commit", |_| {})?; snapbox::assert_data_eq!( @@ -891,7 +885,6 @@ fn reorder_merge_commit_above_keeps_child_commits_visible() -> anyhow::Result<() .raw() ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -907,7 +900,7 @@ fn reorder_merge_commit_above_keeps_child_commits_visible() -> anyhow::Result<() "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let merge_commit = repo.rev_parse_single("M")?.detach(); let merge_commit_selector = editor.select_commit(merge_commit)?; let c1_commit = repo.rev_parse_single("C1")?.detach(); @@ -921,7 +914,7 @@ fn reorder_merge_commit_above_keeps_child_commits_visible() -> anyhow::Result<() )?; rebase.materialize()?; - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; let post_move_graph = visualize_commit_graph_all(&repo)?; @@ -956,7 +949,7 @@ fn reorder_merge_commit_above_keeps_child_commits_visible() -> anyhow::Result<() #[test] fn reorder_merge_commit_below_keeps_child_commits_visible() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph("gb-1525-reorder-merge-commit", |_| {})?; snapbox::assert_data_eq!( @@ -977,7 +970,6 @@ fn reorder_merge_commit_below_keeps_child_commits_visible() -> anyhow::Result<() .raw() ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -993,7 +985,7 @@ fn reorder_merge_commit_below_keeps_child_commits_visible() -> anyhow::Result<() "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let merge_commit = repo.rev_parse_single("M")?.detach(); let merge_commit_selector = editor.select_commit(merge_commit)?; let main_commit = repo.rev_parse_single("main")?.detach(); @@ -1007,7 +999,7 @@ fn reorder_merge_commit_below_keeps_child_commits_visible() -> anyhow::Result<() )?; rebase.materialize()?; - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; let post_move_graph = visualize_commit_graph_all(&repo)?; @@ -1041,7 +1033,7 @@ fn reorder_merge_commit_below_keeps_child_commits_visible() -> anyhow::Result<() #[test] fn reorder_commit_in_non_managed_workspace() -> anyhow::Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = named_writable_scenario_with_description_and_graph("reword-three-commits", |_| {})?; snapbox::assert_data_eq!( @@ -1054,7 +1046,6 @@ fn reorder_commit_in_non_managed_workspace() -> anyhow::Result<()> { "#]] ); - let mut ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1070,7 +1061,7 @@ fn reorder_commit_in_non_managed_workspace() -> anyhow::Result<()> { "#]] ); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let three_commit = repo.rev_parse_single("three")?.detach(); let three_commit_selector = editor.select_commit(three_commit)?; let two_commit = repo.rev_parse_single("two")?.detach(); @@ -1087,7 +1078,7 @@ fn reorder_commit_in_non_managed_workspace() -> anyhow::Result<()> { // Materialize the operation let materialization = rebase.materialize()?; let commit_mappings = materialization.history.commit_mappings(); - let project_meta = ws.graph.project_meta.clone(); + let project_meta = ws.project_meta().clone(); ws.refresh_from_head(&repo, &meta, project_meta)?; let new_commit_two = commit_mappings.get(&two_commit); @@ -1127,10 +1118,12 @@ fn reorder_commit_in_non_managed_workspace() -> anyhow::Result<()> { โŒ‚:three[๐ŸŒณ] <> โœ“! โ””โ”€โ”€ โ‰ก:three[๐ŸŒณ] {1} โ”œโ”€โ”€ :three[๐ŸŒณ] - โ”‚ โ”œโ”€โ”€ ยท09ad3ca โ–บtwo + โ”œโ”€โ”€ :two <> origin/twoโ‡ก2โ‡ฃ1 + โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ16fd221 + โ”‚ โ”œโ”€โ”€ ยท09ad3ca โ”‚ โ””โ”€โ”€ ยท0c38dd9 โ””โ”€โ”€ :one - โ””โ”€โ”€ ยท8b426d0 + โ””โ”€โ”€ โ„8b426d0 "#]] ); diff --git a/crates/but-workspace/tests/workspace/commit/reword.rs b/crates/but-workspace/tests/workspace/commit/reword.rs index 9793dc97ad5..91b1a33566c 100644 --- a/crates/but-workspace/tests/workspace/commit/reword.rs +++ b/crates/but-workspace/tests/workspace/commit/reword.rs @@ -7,7 +7,7 @@ use crate::ref_info::with_workspace_commit::utils::named_writable_scenario_with_ #[test] fn reword_head_commit() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, ws, repo, mut _meta, _description) = writable_scenario("reword-three-commits", |_| {})?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -21,8 +21,7 @@ fn reword_head_commit() -> Result<()> { let head_tree = repo.head_tree_id()?; let id = repo.rev_parse_single("three")?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; reword(editor, id.detach(), b"New name".into())? .0 .materialize()?; @@ -44,7 +43,7 @@ fn reword_head_commit() -> Result<()> { #[test] fn reword_middle_commit() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, ws, repo, mut _meta, _description) = writable_scenario("reword-three-commits", |_| {})?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -58,8 +57,7 @@ fn reword_middle_commit() -> Result<()> { let head_tree = repo.head_tree_id()?; let id = repo.rev_parse_single("two")?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; reword(editor, id.detach(), b"New name".into())? .0 .materialize()?; @@ -83,7 +81,7 @@ fn reword_middle_commit() -> Result<()> { #[test] fn reword_base_commit() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, ws, repo, mut _meta, _description) = writable_scenario("reword-three-commits", |_| {})?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -97,8 +95,7 @@ fn reword_base_commit() -> Result<()> { let head_tree = repo.head_tree_id()?; let id = repo.rev_parse_single("one")?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; reword(editor, id.detach(), b"New name".into())? .0 .materialize()?; diff --git a/crates/but-workspace/tests/workspace/commit/squash_commits.rs b/crates/but-workspace/tests/workspace/commit/squash_commits.rs index 4dba24518aa..9a8598e5b35 100644 --- a/crates/but-workspace/tests/workspace/commit/squash_commits.rs +++ b/crates/but-workspace/tests/workspace/commit/squash_commits.rs @@ -3,7 +3,7 @@ use bstr::ByteSlice as _; use but_rebase::graph_rebase::{Editor, LookupStep}; use but_testsupport::{graph_workspace, visualize_commit_graph_all}; use but_workspace::commit::squash_commits; -use snapbox::IntoData; +use snapbox::prelude::*; use crate::ref_info::with_workspace_commit::utils::{ StackState, add_stack_with_segments, @@ -12,7 +12,7 @@ use crate::ref_info::with_workspace_commit::utils::{ #[test] fn squash_top_commit_into_parent() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, mut ws, repo, mut _meta, _description) = writable_scenario("reword-three-commits", |_| {})?; snapbox::assert_data_eq!( @@ -29,8 +29,7 @@ fn squash_top_commit_into_parent() -> Result<()> { let target_id = repo.rev_parse_single("two")?.detach(); let subject_tree = repo.find_commit(subject_id)?.tree_id()?.detach(); - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; let outcome = squash_commits( editor, vec![subject_id], @@ -39,6 +38,7 @@ fn squash_top_commit_into_parent() -> Result<()> { )?; let materialized = outcome.rebase.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; let squashed_id = materialized.lookup_pick(outcome.commit_selector)?; let squashed_commit = repo.find_commit(squashed_id)?; @@ -80,7 +80,7 @@ fn squash_top_commit_into_parent() -> Result<()> { #[test] fn squash_top_commit_into_parent_keeping_target_message() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, mut ws, repo, mut _meta, _description) = writable_scenario("reword-three-commits", |_| {})?; snapbox::assert_data_eq!( @@ -96,8 +96,7 @@ fn squash_top_commit_into_parent_keeping_target_message() -> Result<()> { let subject_id = repo.rev_parse_single("three")?.detach(); let target_id = repo.rev_parse_single("two")?.detach(); - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; let outcome = squash_commits( editor, vec![subject_id], @@ -106,6 +105,7 @@ fn squash_top_commit_into_parent_keeping_target_message() -> Result<()> { )?; let materialized = outcome.rebase.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; let squashed_id = materialized.lookup_pick(outcome.commit_selector)?; let squashed_commit = repo.find_commit(squashed_id)?; @@ -120,7 +120,7 @@ fn squash_top_commit_into_parent_keeping_target_message() -> Result<()> { #[test] fn squash_top_commit_into_parent_keeping_subject_message() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, mut ws, repo, mut _meta, _description) = writable_scenario("reword-three-commits", |_| {})?; snapbox::assert_data_eq!( @@ -136,8 +136,7 @@ fn squash_top_commit_into_parent_keeping_subject_message() -> Result<()> { let subject_id = repo.rev_parse_single("three")?.detach(); let target_id = repo.rev_parse_single("two")?.detach(); - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; let outcome = squash_commits( editor, vec![subject_id], @@ -146,6 +145,7 @@ fn squash_top_commit_into_parent_keeping_subject_message() -> Result<()> { )?; let materialized = outcome.rebase.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; let squashed_id = materialized.lookup_pick(outcome.commit_selector)?; let squashed_commit = repo.find_commit(squashed_id)?; @@ -160,7 +160,7 @@ fn squash_top_commit_into_parent_keeping_subject_message() -> Result<()> { #[test] fn squash_reorders_when_subject_is_not_on_top() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, mut ws, repo, mut _meta, _description) = writable_scenario("reword-three-commits", |_| {})?; snapbox::assert_data_eq!( @@ -178,8 +178,7 @@ fn squash_reorders_when_subject_is_not_on_top() -> Result<()> { let target_id = repo.rev_parse_single("three")?.detach(); let target_tree = repo.find_commit(target_id)?.tree_id()?.detach(); - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; let outcome = squash_commits( editor, vec![subject_id], @@ -188,6 +187,7 @@ fn squash_reorders_when_subject_is_not_on_top() -> Result<()> { )?; let materialized = outcome.rebase.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; let squashed_id = materialized.lookup_pick(outcome.commit_selector)?; let squashed_commit = repo.find_commit(squashed_id)?; @@ -218,14 +218,13 @@ fn squash_reorders_when_subject_is_not_on_top() -> Result<()> { #[test] fn squash_deduplicates_duplicate_subjects() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, mut ws, repo, mut _meta, _description) = writable_scenario("reword-three-commits", |_| {})?; let subject_id = repo.rev_parse_single("three")?.detach(); let target_id = repo.rev_parse_single("two")?.detach(); - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; let outcome = squash_commits( editor, vec![subject_id, subject_id], @@ -234,6 +233,7 @@ fn squash_deduplicates_duplicate_subjects() -> Result<()> { )?; let materialized = outcome.rebase.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; let squashed_id = materialized.lookup_pick(outcome.commit_selector)?; let squashed_commit = repo.find_commit(squashed_id)?; @@ -248,7 +248,7 @@ fn squash_deduplicates_duplicate_subjects() -> Result<()> { #[test] fn squash_same_commit_is_rejected() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, ws, repo, mut _meta, _description) = writable_scenario("reword-three-commits", |_| {})?; snapbox::assert_data_eq!( @@ -263,8 +263,7 @@ fn squash_same_commit_is_rejected() -> Result<()> { let commit_id = repo.rev_parse_single("two")?.detach(); - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; let err = squash_commits( editor, @@ -294,14 +293,13 @@ fn squash_same_commit_is_rejected() -> Result<()> { #[test] fn squash_rejects_target_in_subject_commit_ids() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, ws, repo, mut _meta, _description) = writable_scenario("reword-three-commits", |_| {})?; let subject_id = repo.rev_parse_single("three")?.detach(); let target_id = repo.rev_parse_single("two")?.detach(); - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; let err = squash_commits( editor, @@ -321,7 +319,7 @@ fn squash_rejects_target_in_subject_commit_ids() -> Result<()> { #[test] fn squash_down_keeps_topmost_tree_for_shared_file_lineage() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, mut ws, repo, mut _meta, _description) = writable_scenario("squash-shared-file-three-commits", |_| {})?; snapbox::assert_data_eq!( @@ -337,8 +335,7 @@ fn squash_down_keeps_topmost_tree_for_shared_file_lineage() -> Result<()> { let subject_id = repo.rev_parse_single("three")?.detach(); let target_id = repo.rev_parse_single("two")?.detach(); - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; let outcome = squash_commits( editor, vec![subject_id], @@ -347,6 +344,7 @@ fn squash_down_keeps_topmost_tree_for_shared_file_lineage() -> Result<()> { )?; let materialized = outcome.rebase.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; let squashed_id = materialized.lookup_pick(outcome.commit_selector)?; let spec = format!("{squashed_id}:shared.txt"); @@ -367,7 +365,7 @@ fn squash_down_keeps_topmost_tree_for_shared_file_lineage() -> Result<()> { #[test] fn squash_move_subject_below_target_for_shared_file_lineage() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, mut ws, repo, mut _meta, _description) = writable_scenario("squash-shared-file-three-commits", |_| {})?; snapbox::assert_data_eq!( @@ -383,8 +381,7 @@ fn squash_move_subject_below_target_for_shared_file_lineage() -> Result<()> { let subject_id = repo.rev_parse_single("two")?.detach(); let target_id = repo.rev_parse_single("three")?.detach(); - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; let outcome = squash_commits( editor, vec![subject_id], @@ -393,6 +390,7 @@ fn squash_move_subject_below_target_for_shared_file_lineage() -> Result<()> { )?; let materialized = outcome.rebase.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; let squashed_id = materialized.lookup_pick(outcome.commit_selector)?; let spec = format!("{squashed_id}:shared.txt"); @@ -419,7 +417,7 @@ fn squash_move_subject_below_target_for_shared_file_lineage() -> Result<()> { #[test] fn squash_move_subject_above_target_out_of_order_for_shared_file_lineage() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, ws, repo, mut _meta, _description) = writable_scenario("squash-shared-file-three-commits", |_| {})?; snapbox::assert_data_eq!( @@ -435,8 +433,7 @@ fn squash_move_subject_above_target_out_of_order_for_shared_file_lineage() -> Re let subject_id = repo.rev_parse_single("three")?.detach(); let target_id = repo.rev_parse_single("one")?.detach(); - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; let err = squash_commits( editor, vec![subject_id], @@ -464,21 +461,20 @@ fn squash_move_subject_above_target_out_of_order_for_shared_file_lineage() -> Re #[test] fn squash_across_stacks_subject_into_target() -> Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = writable_scenario("ws-ref-ws-commit-two-stacks", |meta| { add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); add_stack_with_segments(meta, 2, "B", StackState::InWorkspace, &[]); })?; - let mut ws = graph.into_workspace()?; let normalized = visualize_commit_graph_all(&repo)?.replace(" \n", "\n"); snapbox::assert_data_eq!( normalized, snapbox::str![[r#" -* c49e4d8 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 3147679 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -| * 09d8e52 (A) A -* | c813d8d (B) B +| * c813d8d (B) B +* | 09d8e52 (A) A |/ * 85efbe4 (origin/main, main) M @@ -503,7 +499,7 @@ fn squash_across_stacks_subject_into_target() -> Result<()> { let target_id = repo.rev_parse_single("B")?.detach(); let subject_tree = repo.find_commit(subject_id)?.tree_id()?.detach(); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let outcome = squash_commits( editor, vec![subject_id], @@ -512,6 +508,7 @@ fn squash_across_stacks_subject_into_target() -> Result<()> { )?; let materialized = outcome.rebase.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; let squashed_id = materialized.lookup_pick(outcome.commit_selector)?; let squashed_commit = repo.find_commit(squashed_id)?; @@ -521,9 +518,9 @@ fn squash_across_stacks_subject_into_target() -> Result<()> { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* 5eaffd7 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 1bf18d8 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -* | a2dc4c7 (B) B +| * a2dc4c7 (B) B |/ * 85efbe4 (origin/main, main, A) M @@ -548,22 +545,20 @@ fn squash_across_stacks_subject_into_target() -> Result<()> { #[test] fn squash_across_stacks_target_into_subject() -> Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = writable_scenario("ws-ref-ws-commit-two-stacks", |meta| { add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); add_stack_with_segments(meta, 2, "B", StackState::InWorkspace, &[]); })?; - let mut ws = graph.into_workspace()?; - let normalized = visualize_commit_graph_all(&repo)?.replace(" \n", "\n"); snapbox::assert_data_eq!( normalized, snapbox::str![[r#" -* c49e4d8 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 3147679 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -| * 09d8e52 (A) A -* | c813d8d (B) B +| * c813d8d (B) B +* | 09d8e52 (A) A |/ * 85efbe4 (origin/main, main) M @@ -589,7 +584,7 @@ fn squash_across_stacks_target_into_subject() -> Result<()> { let target_id = repo.rev_parse_single("A")?.detach(); let subject_tree = repo.find_commit(subject_id)?.tree_id()?.detach(); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let outcome = squash_commits( editor, vec![subject_id], @@ -598,6 +593,7 @@ fn squash_across_stacks_target_into_subject() -> Result<()> { )?; let materialized = outcome.rebase.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; let squashed_id = materialized.lookup_pick(outcome.commit_selector)?; let squashed_commit = repo.find_commit(squashed_id)?; @@ -607,9 +603,9 @@ fn squash_across_stacks_target_into_subject() -> Result<()> { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* 2e7b9b5 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 8ffe26f (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -| * 80db672 (A) A +* | 80db672 (A) A |/ * 85efbe4 (origin/main, main, B) M @@ -634,7 +630,7 @@ fn squash_across_stacks_target_into_subject() -> Result<()> { #[test] fn squash_cross_stack_commit_does_not_pull_in_ancestor_tree_state() -> Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = writable_scenario("ws-ref-ws-commit-single-stack-double-stack-files", |meta| { add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); add_stack_with_segments(meta, 2, "C", StackState::InWorkspace, &["B"]); @@ -644,11 +640,11 @@ fn squash_cross_stack_commit_does_not_pull_in_ancestor_tree_state() -> Result<() snapbox::assert_data_eq!( normalized, snapbox::str![[r#" -* c47834b (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 12c47aa (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -| * 26e45af (A) A -* | 356de85 (C) C -* | f25f65c (B) B +| * 356de85 (C) C +| * f25f65c (B) B +* | 26e45af (A) A |/ * 893d602 (origin/main, main) M @@ -656,11 +652,10 @@ fn squash_cross_stack_commit_does_not_pull_in_ancestor_tree_state() -> Result<() .raw() ); - let mut ws = graph.into_workspace()?; let subject_id = repo.rev_parse_single("C")?.detach(); let target_id = repo.rev_parse_single("A")?.detach(); - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let outcome = squash_commits( editor, vec![subject_id], @@ -669,6 +664,7 @@ fn squash_cross_stack_commit_does_not_pull_in_ancestor_tree_state() -> Result<() )?; let materialized = outcome.rebase.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; let squashed_id = materialized.lookup_pick(outcome.commit_selector)?; let file_a = repo @@ -703,7 +699,7 @@ fn squash_cross_stack_commit_does_not_pull_in_ancestor_tree_state() -> Result<() #[test] fn squash_cross_stack_commit_with_deeper_stacks_does_not_pull_in_ancestor_tree_state() -> Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, mut ws, repo, mut meta, _description) = writable_scenario("ws-ref-ws-commit-double-stack-triple-stack-files", |meta| { add_stack_with_segments(meta, 1, "D", StackState::InWorkspace, &["A"]); add_stack_with_segments(meta, 2, "E", StackState::InWorkspace, &["B", "C"]); @@ -713,13 +709,13 @@ fn squash_cross_stack_commit_with_deeper_stacks_does_not_pull_in_ancestor_tree_s snapbox::assert_data_eq!( normalized, snapbox::str![[r#" -* 8cf5961 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 813c644 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -| * e352141 (D) D -| * 26e45af (A) A -* | b9b4be4 (E) E -* | 356de85 (C) C -* | f25f65c (B) B +| * b9b4be4 (E) E +| * 356de85 (C) C +| * f25f65c (B) B +* | e352141 (D) D +* | 26e45af (A) A |/ * 893d602 (origin/main, main) M @@ -733,8 +729,7 @@ fn squash_cross_stack_commit_with_deeper_stacks_does_not_pull_in_ancestor_tree_s let c_id = repo.rev_parse_single("C")?.detach(); let e_id = repo.rev_parse_single("E")?.detach(); - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let outcome = squash_commits( editor, vec![e_id], @@ -743,18 +738,19 @@ fn squash_cross_stack_commit_with_deeper_stacks_does_not_pull_in_ancestor_tree_s )?; let materialized = outcome.rebase.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; let squashed_id = materialized.lookup_pick(outcome.commit_selector)?; let normalized = visualize_commit_graph_all(&repo)?.replace(" \n", "\n"); snapbox::assert_data_eq!( normalized, snapbox::str![[r#" -* c9040ff (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* d858c64 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -| * 56cb644 (D) D -| * 26e45af (A) A -* | 356de85 (E, C) C -* | f25f65c (B) B +| * 356de85 (E, C) C +| * f25f65c (B) B +* | 56cb644 (D) D +* | 26e45af (A) A |/ * 893d602 (origin/main, main) M @@ -901,7 +897,7 @@ fn squash_cross_stack_commit_with_deeper_stacks_does_not_pull_in_ancestor_tree_s #[test] fn squash_all_c_commits_into_second_commit_of_b_keeps_new_file_content() -> Result<()> { - let (_tmp, graph, repo, mut meta, _description) = writable_scenario("three-stacks", |meta| { + let (_tmp, mut ws, repo, mut meta, _description) = writable_scenario("three-stacks", |meta| { add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); add_stack_with_segments(meta, 2, "B", StackState::InWorkspace, &[]); add_stack_with_segments(meta, 3, "C", StackState::InWorkspace, &[]); @@ -912,8 +908,7 @@ fn squash_all_c_commits_into_second_commit_of_b_keeps_new_file_content() -> Resu let c_third = repo.rev_parse_single("C~2")?.detach(); let target_id = repo.rev_parse_single("B~1")?.detach(); - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let outcome = squash_commits( editor, vec![c_top, c_second, c_third], @@ -922,6 +917,7 @@ fn squash_all_c_commits_into_second_commit_of_b_keeps_new_file_content() -> Resu )?; let materialized = outcome.rebase.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; let squashed_id = materialized.lookup_pick(outcome.commit_selector)?; let new_file_blob = repo diff --git a/crates/but-workspace/tests/workspace/commit/uncommit_changes.rs b/crates/but-workspace/tests/workspace/commit/uncommit_changes.rs index e74e9053232..9b9de55ae68 100644 --- a/crates/but-workspace/tests/workspace/commit/uncommit_changes.rs +++ b/crates/but-workspace/tests/workspace/commit/uncommit_changes.rs @@ -6,7 +6,7 @@ use but_workspace::commit::{ UncommitChangesSource, uncommit_changes, uncommit_changes_from_commits, }; use gix::prelude::ObjectIdExt; -use snapbox::IntoData; +use snapbox::prelude::*; use std::collections::HashMap; use crate::ref_info::with_workspace_commit::utils::named_writable_scenario_with_description_and_graph as writable_scenario; @@ -108,7 +108,7 @@ fn assert_worktree_file(repo: &gix::Repository, path: &str, expected: &str) { #[test] fn uncommit_file_from_head() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, ws, repo, mut _meta, _description) = writable_scenario("reword-three-commits", |_| {})?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -137,8 +137,7 @@ e0495e9 ); // Uncommit three.txt from commit three - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; let outcome = uncommit_changes(editor, three_id, vec![diff_spec_for_file("three.txt")], 0)?; let materialized = outcome.rebase.materialize()?; @@ -173,7 +172,7 @@ aac5238 #[test] fn uncommit_file_from_parent() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, ws, repo, mut _meta, _description) = writable_scenario("reword-three-commits", |_| {})?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -201,8 +200,7 @@ aac5238 ); // Uncommit two.txt from commit two - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; let outcome = uncommit_changes(editor, two_id, vec![diff_spec_for_file("two.txt")], 0)?; let materialized = outcome.rebase.materialize()?; @@ -252,7 +250,7 @@ c97666c #[test] fn uncommit_file_from_root_commit() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, ws, repo, mut _meta, _description) = writable_scenario("reword-three-commits", |_| {})?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -279,8 +277,7 @@ fn uncommit_file_from_root_commit() -> Result<()> { ); // Uncommit one.txt from commit one (the root commit) - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; let outcome = uncommit_changes(editor, one_id, vec![diff_spec_for_file("one.txt")], 0)?; let materialized = outcome.rebase.materialize()?; @@ -315,14 +312,13 @@ f2ff419 #[test] fn error_when_changes_not_found() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, ws, repo, mut _meta, _description) = writable_scenario("reword-three-commits", |_| {})?; let three_id = repo.rev_parse_single("three")?.detach(); // Try to uncommit a file that doesn't exist in source commit - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; let result = uncommit_changes( editor, three_id, @@ -343,7 +339,7 @@ fn error_when_changes_not_found() -> Result<()> { #[test] fn uncommit_empty_changes_is_noop() -> Result<()> { - let (_tmp, graph, repo, mut _meta, _description) = + let (_tmp, ws, repo, mut _meta, _description) = writable_scenario("reword-three-commits", |_| {})?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -358,8 +354,7 @@ fn uncommit_empty_changes_is_noop() -> Result<()> { let three_id = repo.rev_parse_single("three")?.detach(); // Uncommit with empty changes should effectively be a no-op rebase - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut _meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut _meta, &repo)?; let outcome = uncommit_changes(editor, three_id, Vec::::new(), 0)?; outcome.rebase.materialize()?; @@ -380,7 +375,7 @@ fn uncommit_empty_changes_is_noop() -> Result<()> { #[test] fn uncommit_changes_from_commits_groups_and_orders_sources() -> Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = writable_scenario("reword-three-commits", |_| {})?; let one_id = repo.rev_parse_single("one")?.detach(); @@ -388,8 +383,7 @@ fn uncommit_changes_from_commits_groups_and_orders_sources() -> Result<()> { let three_id = repo.rev_parse_single("three")?.detach(); let graph_before = visualize_commit_graph_all(&repo)?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let outcome = uncommit_changes_from_commits( editor, [ @@ -453,14 +447,13 @@ f2ff419 #[test] fn uncommit_changes_from_commits_removes_multiple_changes_from_one_source() -> Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = writable_scenario("reword-three-commits", |_| {})?; let one_id = repo.rev_parse_single("one")?.detach(); let graph_before = visualize_commit_graph_all(&repo)?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; // A single source carries several changes for the same commit. let outcome = uncommit_changes_from_commits( editor, @@ -512,14 +505,13 @@ fn uncommit_changes_from_commits_removes_multiple_changes_from_one_source() -> R #[test] fn uncommit_changes_from_commits_groups_duplicate_commit_ids() -> Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = writable_scenario("reword-three-commits", |_| {})?; let one_id = repo.rev_parse_single("one")?.detach(); let graph_before = visualize_commit_graph_all(&repo)?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; // Two separate sources point at the same commit and must be grouped so that // both changes are removed in a single tree replacement. let outcome = uncommit_changes_from_commits( @@ -572,15 +564,14 @@ fn uncommit_changes_from_commits_groups_duplicate_commit_ids() -> Result<()> { #[test] fn uncommit_changes_from_commits_reports_failures_and_materializes_successes() -> Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = writable_scenario("reword-three-commits", |_| {})?; let one_id = repo.rev_parse_single("one")?.detach(); let three_id = repo.rev_parse_single("three")?.detach(); let graph_before = visualize_commit_graph_all(&repo)?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let outcome = uncommit_changes_from_commits( editor, [ @@ -645,14 +636,13 @@ aac5238 #[test] fn uncommit_changes_from_commits_all_failures_does_not_rebase() -> Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = writable_scenario("reword-three-commits", |_| {})?; let one_id = repo.rev_parse_single("one")?.detach(); let three_before = repo.rev_parse_single("three")?.detach(); - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let outcome = uncommit_changes_from_commits( editor, [ @@ -678,14 +668,13 @@ fn uncommit_changes_from_commits_all_failures_does_not_rebase() -> Result<()> { #[test] fn uncommit_changes_from_commits_can_uncommit_selected_lines() -> Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = writable_scenario("uncommit-lines-from-file", |_| {})?; let branch_id = repo.rev_parse_single("branch")?.detach(); let graph_before = visualize_commit_graph_all(&repo)?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let outcome = uncommit_changes_from_commits( editor, [source_with_hunks( @@ -746,14 +735,13 @@ fn uncommit_changes_from_commits_can_uncommit_selected_lines() -> Result<()> { #[test] fn uncommit_changes_from_commits_merges_multiple_specs_for_same_file() -> Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = writable_scenario("uncommit-two-hunks-from-file", |_| {})?; let branch_id = repo.rev_parse_single("branch")?.detach(); let graph_before = visualize_commit_graph_all(&repo)?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; // Two separate sources target the same file in the same commit, one per hunk. // They must be merged into a single spec so both hunks are removed; without // merging, the tree rebuild would keep only the last spec's change. @@ -813,13 +801,12 @@ fn uncommit_changes_from_commits_merges_multiple_specs_for_same_file() -> Result #[test] fn uncommit_changes_from_commits_whole_file_spec_supersedes_hunk_specs() -> Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = writable_scenario("uncommit-two-hunks-from-file", |_| {})?; let branch_id = repo.rev_parse_single("branch")?.detach(); - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; // A single source mixes a whole-file spec (empty hunks) with a hunk spec for // the same file. The whole-file spec must win, removing every change rather // than just the named hunk. @@ -866,15 +853,14 @@ fn uncommit_changes_from_commits_whole_file_spec_supersedes_hunk_specs() -> Resu #[test] fn uncommit_changes_from_commits_handles_parallel_stacks() -> Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = writable_scenario("uncommit-from-parallel-stacks", |_| {})?; let stack_a_id = repo.rev_parse_single("stack-a")?.detach(); let stack_b_id = repo.rev_parse_single("stack-b")?.detach(); let graph_before = visualize_commit_graph_all(&repo)?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let outcome = uncommit_changes_from_commits( editor, [source(stack_b_id, "b.txt"), source(stack_a_id, "a.txt")], @@ -908,10 +894,9 @@ fn uncommit_changes_from_commits_handles_parallel_stacks() -> Result<()> { +++ after +*-. [C5] (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|\ \ -+| | * [C6] (stack-b) stack B adds file ++| * | [C6] (stack-b) stack B adds file +| |/ -+|/| -+| * [C7] (stack-a) stack A adds file ++* / [C7] (stack-a) stack A adds file +|/ +* [C4] (main) base @@ -941,7 +926,7 @@ fn uncommit_changes_from_commits_handles_parallel_stacks() -> Result<()> { #[test] fn uncommit_changes_from_commits_handles_unordered_overwrites_of_same_file() -> Result<()> { - let (_tmp, graph, repo, mut meta, _description) = + let (_tmp, ws, repo, mut meta, _description) = writable_scenario("uncommit-overwritten-file-three-commits", |_| {})?; let one_id = repo.rev_parse_single("branch~2")?.detach(); @@ -949,8 +934,7 @@ fn uncommit_changes_from_commits_handles_unordered_overwrites_of_same_file() -> let three_id = repo.rev_parse_single("branch")?.detach(); let graph_before = visualize_commit_graph_all(&repo)?; - let mut ws = graph.into_workspace()?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let outcome = uncommit_changes_from_commits( editor, [ diff --git a/crates/but-workspace/tests/workspace/graph.rs b/crates/but-workspace/tests/workspace/graph.rs index 7e7db88abc8..4c09455a40b 100644 --- a/crates/but-workspace/tests/workspace/graph.rs +++ b/crates/but-workspace/tests/workspace/graph.rs @@ -12,7 +12,7 @@ use anyhow::Result; use but_core::{RefMetadata as _, ref_metadata::ProjectMeta}; -use but_graph::{Graph, init::Options}; +use but_graph::walk::Options; use but_meta::{VirtualBranchesTomlMetadata, virtual_branches_legacy_types::Target}; use but_testsupport::{gix_testtools::tempfile::TempDir, visualize_commit_graph_all}; use but_workspace::workspace::{ @@ -20,7 +20,7 @@ use but_workspace::workspace::{ }; use gix::bstr::ByteSlice; use renderdag::{LinkLine, NodeLine}; -use snapbox::IntoData; +use snapbox::prelude::*; use crate::ref_info::with_workspace_commit::utils::{ StackState, add_stack, add_stack_with_segments, named_writable_scenario_with_description, @@ -48,9 +48,8 @@ fn detailed( .transpose()?, ..Default::default() }; - let graph = Graph::from_head(&repo, &meta, project_meta, Options::limited())?; - let mut ws = graph.into_workspace()?; - let detailed = detailed_graph_workspace(&mut ws, &mut meta, &repo)?; + let ws = but_graph::Workspace::from_head(&repo, &meta, project_meta, Options::limited())?; + let detailed = detailed_graph_workspace(&ws, &mut meta, &repo)?; Ok((repo, detailed)) } @@ -76,7 +75,7 @@ fn detailed_writable( let project_meta = meta .workspace(but_core::WORKSPACE_REF_NAME.try_into()?)? .project_meta(); - let graph = Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &meta, project_meta, @@ -85,8 +84,7 @@ fn detailed_writable( ..Options::limited() }, )?; - let mut ws = graph.into_workspace()?; - let detailed = detailed_graph_workspace(&mut ws, &mut meta, &repo)?; + let detailed = detailed_graph_workspace(&ws, &mut meta, &repo)?; Ok((tmp, detailed)) } @@ -552,7 +550,7 @@ fn divergent_stacks_sharing_base_merge() -> Result<()> { /// The same divergent fixture bounded by a target at `main` (which sits at /// `base`). #[test] -fn divergent_stacks_sharing_base_merge_with_target() -> Result<()> { +fn divergent_stacks_sharing_excluded_base_stay_separate_with_target() -> Result<()> { let (_repo, detailed) = detailed("workspace-two-stacks", Some("refs/heads/main"))?; snapbox::assert_data_eq!( render(&detailed), @@ -561,17 +559,15 @@ fn divergent_stacks_sharing_base_merge_with_target() -> Result<()> { โ—Ž 0 refs/heads/stack-a โ— 1 49c06ff A2 โ— 2 ff76d2f A1 -โ”‚ โ—Ž 3 refs/heads/stack-b -โ”‚ โ— 4 afc3f8f B2 -โ”‚ โ— 5 b3ee99c B1 -โ”œโ”€โ•ฏ -โ—Ž 6 refs/heads/main linear ref=0 rows=[0, 1, 2] - linear ref=3 rows=[3, 4, 5] - linear ref=6 rows=[6] reference ref=0 rows=[0, 1, 2] - reference ref=3 rows=[3, 4, 5] - reference ref=6 rows=[6] + +# Stack 1 +โ—Ž 0 refs/heads/stack-b +โ— 1 afc3f8f B2 +โ— 2 b3ee99c B1 + linear ref=0 rows=[0, 1, 2] + reference ref=0 rows=[0, 1, 2] "#]] ); Ok(()) @@ -630,13 +626,6 @@ fn disjoint_stacks_stay_separate() -> Result<()> { render(&detailed), snapbox::str![[r#" # Stack 0 -โ—Ž 0 refs/heads/stack-b -โ— 1 cb7021b B2 -โ— 2 ce3278a B1 - linear ref=0 rows=[0, 1, 2] - reference ref=0 rows=[0, 1, 2] - -# Stack 1 โ—Ž 0 refs/heads/stack-a โ— 1 49c06ff A2 โ— 2 ff76d2f A1 @@ -646,6 +635,13 @@ fn disjoint_stacks_stay_separate() -> Result<()> { linear ref=3 rows=[3, 4] reference ref=0 rows=[0, 1, 2] reference ref=3 rows=[3, 4] + +# Stack 1 +โ—Ž 0 refs/heads/stack-b +โ— 1 cb7021b B2 +โ— 2 ce3278a B1 + linear ref=0 rows=[0, 1, 2] + reference ref=0 rows=[0, 1, 2] "#]] ); Ok(()) @@ -659,21 +655,18 @@ fn disjoint_stacks_stay_separate_with_target() -> Result<()> { render(&detailed), snapbox::str![[r#" # Stack 0 -โ—Ž 0 refs/heads/stack-b -โ— 1 cb7021b B2 -โ— 2 ce3278a B1 +โ—Ž 0 refs/heads/stack-a +โ— 1 49c06ff A2 +โ— 2 ff76d2f A1 linear ref=0 rows=[0, 1, 2] reference ref=0 rows=[0, 1, 2] # Stack 1 -โ—Ž 0 refs/heads/stack-a -โ— 1 49c06ff A2 -โ— 2 ff76d2f A1 -โ—Ž 3 refs/heads/main +โ—Ž 0 refs/heads/stack-b +โ— 1 cb7021b B2 +โ— 2 ce3278a B1 linear ref=0 rows=[0, 1, 2] - linear ref=3 rows=[3] reference ref=0 rows=[0, 1, 2] - reference ref=3 rows=[3] "#]] ); Ok(()) @@ -736,13 +729,10 @@ fn stacked_dependent_branches_with_target() -> Result<()> { โ—Ž 3 refs/heads/branch-bottom โ— 4 d27b21f bottom 2 โ— 5 a3a5a44 bottom 1 -โ—Ž 6 refs/heads/main linear ref=0 rows=[0, 1, 2] linear ref=3 rows=[3, 4, 5] - linear ref=6 rows=[6] reference ref=0 rows=[0, 1, 2] reference ref=3 rows=[3, 4, 5] - reference ref=6 rows=[6] "#]] ); Ok(()) @@ -853,8 +843,8 @@ fn push_status_nothing_to_push_and_unpushed() -> Result<()> { snapbox::str![[r#" # Stack 0 refs/heads/stack-a push=CompletelyUnpushed combined=CompletelyUnpushed remote=- +# Stack 1 refs/heads/stack-b push=CompletelyUnpushed combined=CompletelyUnpushed remote=- -refs/heads/main push=NothingToPush combined=NothingToPush remote=refs/remotes/origin/main "#]] ); Ok(()) @@ -889,8 +879,8 @@ fn integration_status_marks_fully_integrated_branch() -> Result<()> { snapbox::str![[r#" # Stack 0 refs/heads/A push=Integrated combined=Integrated remote=- +# Stack 1 refs/heads/B push=CompletelyUnpushed combined=UnpushedCommitsRequiringForce remote=- -refs/heads/main push=UnpushedCommitsRequiringForce combined=UnpushedCommitsRequiringForce remote=refs/remotes/origin/main "#]] ); // `add A1` (== origin/main) is integrated; `add B1` is local-only. @@ -899,6 +889,7 @@ refs/heads/main push=UnpushedCommitsRequiringForce combined=UnpushedCommitsRequ snapbox::str![[r#" # Stack 0 905d6e5 add A1 state=integrated +# Stack 1 b38b04b add B1 state=local "#]] ); @@ -984,12 +975,13 @@ fn integration_status_marks_partially_integrated_multi_branch_stack() -> Result< # Stack 0 refs/heads/A push=CompletelyUnpushed combined=UnpushedCommitsRequiringForce remote=- refs/heads/C push=Integrated combined=Integrated remote=- +# Stack 1 refs/heads/B push=CompletelyUnpushed combined=UnpushedCommitsRequiringForce remote=- -refs/heads/main push=UnpushedCommitsRequiringForce combined=UnpushedCommitsRequiringForce remote=refs/remotes/origin/main # Stack 0 44c9428 add A1 state=local f1e7451 add C1 state=integrated +# Stack 1 b38b04b add B1 state=local "#]] ); @@ -1016,12 +1008,13 @@ fn integration_status_marks_fully_integrated_multi_branch_stack() -> Result<()> # Stack 0 refs/heads/A push=Integrated combined=Integrated remote=- refs/heads/C push=Integrated combined=Integrated remote=- +# Stack 1 refs/heads/B push=CompletelyUnpushed combined=UnpushedCommitsRequiringForce remote=- -refs/heads/main push=UnpushedCommitsRequiringForce combined=UnpushedCommitsRequiringForce remote=refs/remotes/origin/main # Stack 0 44c9428 add A1 state=integrated f1e7451 add C1 state=integrated +# Stack 1 b38b04b add B1 state=local "#]] ); @@ -1046,14 +1039,14 @@ fn integration_status_marks_fully_integrated_two_stacks() -> Result<()> { render_statuses(&detailed), snapbox::str![[r#" # Stack 0 -refs/heads/B push=Integrated combined=Integrated remote=- -# Stack 1 refs/heads/A push=Integrated combined=Integrated remote=- +# Stack 1 +refs/heads/B push=Integrated combined=Integrated remote=- # Stack 0 -b38b04b add B1 state=integrated -# Stack 1 905d6e5 add A1 state=integrated +# Stack 1 +b38b04b add B1 state=integrated "#]] ); Ok(()) @@ -1157,7 +1150,7 @@ fn commit_state_uses_similarity_for_local_and_remote() -> Result<()> { let target_sha = project_meta .target_commit_id .context("scenario should configure a target")?; - let graph = Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &*meta, project_meta, @@ -1166,8 +1159,7 @@ fn commit_state_uses_similarity_for_local_and_remote() -> Result<()> { ..Options::limited() }, )?; - let mut ws = graph.into_workspace()?; - let detailed = detailed_graph_workspace(&mut ws, &mut *meta, &repo)?; + let detailed = detailed_graph_workspace(&ws, &mut *meta, &repo)?; snapbox::assert_data_eq!( render_commit_state(&detailed), snapbox::str![[r#" diff --git a/crates/but-workspace/tests/workspace/ref_info/mod.rs b/crates/but-workspace/tests/workspace/ref_info/mod.rs index 01d6cfc1228..09826f12883 100644 --- a/crates/but-workspace/tests/workspace/ref_info/mod.rs +++ b/crates/but-workspace/tests/workspace/ref_info/mod.rs @@ -140,7 +140,7 @@ fn unborn_untracked() -> anyhow::Result<()> { // It's clear that this branch is unborn as there is not a single commit, // in absence of a target ref. snapbox::assert_data_eq!( - info.to_debug(), + &info.to_debug(), snapbox::str![[r#" RefInfo { workspace_ref_info: Some( @@ -179,8 +179,6 @@ RefInfo { }, ], target_ref: None, - target_commit: None, - lower_bound: None, is_managed_ref: false, is_managed_commit: false, ancestor_workspace_commit: None, @@ -194,7 +192,7 @@ RefInfo { // It's now possible to use the old API with unborn repos. // This type can't really represent missing tips, but `null()` will do. snapbox::assert_data_eq!( - stacks.to_debug(), + &stacks.to_debug(), snapbox::str![[r#" [ StackEntry { @@ -219,7 +217,7 @@ RefInfo { let details = stack_details_v3(stacks[0].id, &repo, &meta)?; // It's also possible to obtain details. snapbox::assert_data_eq!( - details.to_debug(), + &details.to_debug(), snapbox::str![[r#" StackDetails { derived_name: "main", @@ -260,7 +258,7 @@ fn detached() -> anyhow::Result<()> { // As the workspace name is derived from the first segment, it's empty as well. // We do know that `main` is pointing at the local commit though, despite the unnamed segment owning it. snapbox::assert_data_eq!( - info.to_debug(), + &info.to_debug(), snapbox::str![[r#" RefInfo { workspace_ref_info: None, @@ -288,8 +286,6 @@ RefInfo { }, ], target_ref: None, - target_commit: None, - lower_bound: None, is_managed_ref: false, is_managed_commit: false, ancestor_workspace_commit: None, @@ -303,7 +299,7 @@ RefInfo { let stacks = stacks_v3(&repo, &meta, StacksFilter::All, None)?; // Detached heads can't be represented with this API as it really needs a name. snapbox::assert_data_eq!( - stacks.to_debug(), + &stacks.to_debug(), snapbox::str![[r#" [] @@ -324,7 +320,7 @@ fn conflicted_in_local_branch() -> anyhow::Result<()> { let info = head_info(&repo, &meta, ref_info::Options::default())?; // The conflict is detected in the local commit. snapbox::assert_data_eq!( - info.to_debug(), + &info.to_debug(), snapbox::str![[r#" RefInfo { workspace_ref_info: Some( @@ -368,8 +364,6 @@ RefInfo { }, ], target_ref: None, - target_commit: None, - lower_bound: None, is_managed_ref: false, is_managed_commit: false, ancestor_workspace_commit: None, @@ -382,7 +376,7 @@ RefInfo { let stacks = stacks_v3(&repo, &meta, StacksFilter::All, None)?; snapbox::assert_data_eq!( - stacks.to_debug(), + &stacks.to_debug(), snapbox::str![[r#" [ StackEntry { @@ -458,7 +452,7 @@ fn single_branch() -> anyhow::Result<()> { "a single branch, a single segment" ); snapbox::assert_data_eq!( - info.to_debug(), + &info.to_debug(), snapbox::str![[r#" RefInfo { workspace_ref_info: Some( @@ -510,8 +504,6 @@ RefInfo { }, ], target_ref: None, - target_commit: None, - lower_bound: None, is_managed_ref: false, is_managed_commit: false, ancestor_workspace_commit: None, @@ -524,7 +516,7 @@ RefInfo { let stacks = stacks_v3(&repo, &meta, StacksFilter::All, None)?; snapbox::assert_data_eq!( - stacks.to_debug(), + &stacks.to_debug(), snapbox::str![[r#" [ StackEntry { @@ -601,7 +593,7 @@ fn single_branch_multiple_segments() -> anyhow::Result<()> { let info = head_info(&repo, &meta, standard_options())?; snapbox::assert_data_eq!( - info.to_debug(), + &info.to_debug(), snapbox::str![[r#" RefInfo { workspace_ref_info: Some( @@ -697,8 +689,6 @@ RefInfo { }, ], target_ref: None, - target_commit: None, - lower_bound: None, is_managed_ref: false, is_managed_commit: false, ancestor_workspace_commit: None, @@ -713,7 +703,7 @@ RefInfo { let stacks = stacks_v3(&repo, &meta, StacksFilter::All, None)?; snapbox::assert_data_eq!( - stacks.to_debug(), + &stacks.to_debug(), snapbox::str![[r#" [ StackEntry { diff --git a/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/journey/exhaustive_with_squash_merges.rs b/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/journey/exhaustive_with_squash_merges.rs index 6f813911814..8d8e0b650de 100644 --- a/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/journey/exhaustive_with_squash_merges.rs +++ b/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/journey/exhaustive_with_squash_merges.rs @@ -69,8 +69,6 @@ Ok( }, ], target_ref: None, - target_commit: None, - lower_bound: None, is_managed_ref: false, is_managed_commit: false, ancestor_workspace_commit: None, @@ -145,14 +143,6 @@ Ok( }, ], target_ref: None, - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(0), - ), is_managed_ref: false, is_managed_commit: false, ancestor_workspace_commit: None, @@ -238,14 +228,6 @@ Ok( commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(0), - ), is_managed_ref: false, is_managed_commit: false, ancestor_workspace_commit: None, @@ -314,14 +296,6 @@ Ok( commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(0), - ), is_managed_ref: false, is_managed_commit: false, ancestor_workspace_commit: None, @@ -389,14 +363,6 @@ Ok( commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -486,14 +452,6 @@ Ok( commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -582,14 +540,6 @@ Ok( commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -659,14 +609,6 @@ Ok( commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -758,14 +700,6 @@ Ok( commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -862,14 +796,6 @@ Ok( commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -965,14 +891,6 @@ Ok( commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -1073,14 +991,6 @@ Ok( commits_ahead: 2, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -1194,14 +1104,6 @@ Ok( commits_ahead: 5, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(adc9f0cd07bd0a09363ac6536291bf821ca845c4), - }, - ), - lower_bound: Some( - NodeIndex(5), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -1353,14 +1255,6 @@ Ok( commits_ahead: 7, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(35faa22c8d0a01ba45da3971406eab6932b1bbde), - }, - ), - lower_bound: Some( - NodeIndex(8), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, diff --git a/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/journey/integrate_with_merges.rs b/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/journey/integrate_with_merges.rs index edff76f1f42..e580ba8e79e 100644 --- a/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/journey/integrate_with_merges.rs +++ b/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/journey/integrate_with_merges.rs @@ -93,14 +93,6 @@ Ok( commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -195,14 +187,6 @@ Ok( commits_ahead: 2, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -299,14 +283,6 @@ Ok( commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -410,14 +386,6 @@ Ok( commits_ahead: 2, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(085089cbf8a35fa549a5d50bd74930a7fddf970d), - }, - ), - lower_bound: Some( - NodeIndex(3), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -508,14 +476,6 @@ Ok( commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -616,14 +576,6 @@ Ok( commits_ahead: 1, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(a670cd571f1a6946a1a87d107e909445aa0fe90d), - }, - ), - lower_bound: Some( - NodeIndex(3), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -716,14 +668,6 @@ Ok( commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -818,14 +762,6 @@ Ok( commits_ahead: 1, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(a62b0de7d50898e05c6cfa5b56d268aa5be17087), - }, - ), - lower_bound: Some( - NodeIndex(3), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, diff --git a/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/journey/integrate_with_rebase.rs b/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/journey/integrate_with_rebase.rs index 2d69ef37c5a..97e7c021c69 100644 --- a/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/journey/integrate_with_rebase.rs +++ b/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/journey/integrate_with_rebase.rs @@ -93,14 +93,6 @@ Ok( commits_ahead: 5, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(eabf2989a998260c7fbe181b33d5772705d62907), - }, - ), - lower_bound: Some( - NodeIndex(5), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -200,14 +192,6 @@ Ok( commits_ahead: 5, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(d89aadb67d5c32e6a63cad3d36020b5e8e192a91), - }, - ), - lower_bound: Some( - NodeIndex(5), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -304,14 +288,6 @@ Ok( commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -411,14 +387,6 @@ Ok( commits_ahead: 5, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(7a2d071f19ec7551996099943167460ff2c2dd9d), - }, - ), - lower_bound: Some( - NodeIndex(5), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, diff --git a/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/journey/mod.rs b/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/journey/mod.rs index f0fe47bbd01..2294d1c40d0 100644 --- a/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/journey/mod.rs +++ b/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/journey/mod.rs @@ -9,7 +9,7 @@ mod utils { revspec: &str, ) -> but_workspace::ref_info::Options<'static> { but_workspace::ref_info::Options { - traversal: but_graph::init::Options { + traversal: but_graph::walk::Options { extra_target_commit_id: repo.rev_parse_single(revspec).unwrap().detach().into(), ..Default::default() }, diff --git a/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/legacy.rs b/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/legacy.rs index c7ac169c60a..ec37d3ee392 100644 --- a/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/legacy.rs +++ b/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/legacy.rs @@ -35,10 +35,10 @@ mod stacks { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* 820f2b3 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* fe1a116 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -| * 4e5484a (B-on-A) add new file in B-on-A -* | 5f37dbf (C-on-A) add new file in C-on-A +| * 5f37dbf (C-on-A) add new file in C-on-A +* | 4e5484a (B-on-A) add new file in B-on-A |/ | * 89cc2d3 (origin/A) change in A |/ @@ -56,12 +56,12 @@ mod stacks { [ StackEntry { id: Some( - 00000000-0000-0000-0000-000000000002, + 00000000-0000-0000-0000-000000000001, ), heads: [ StackHeadInfo { - name: "C-on-A", - tip: Sha1(5f37dbfd4b1c3d2ee75f216665ab4edf44c843cb), + name: "B-on-A", + tip: Sha1(4e5484ac0f1da1909414b1e16bd740c1a3599509), review_id: None, is_checked_out: false, }, @@ -72,18 +72,18 @@ mod stacks { is_checked_out: false, }, ], - tip: Sha1(5f37dbfd4b1c3d2ee75f216665ab4edf44c843cb), + tip: Sha1(4e5484ac0f1da1909414b1e16bd740c1a3599509), order: None, is_checked_out: false, }, StackEntry { id: Some( - 00000000-0000-0000-0000-000000000001, + 00000000-0000-0000-0000-000000000002, ), heads: [ StackHeadInfo { - name: "B-on-A", - tip: Sha1(4e5484ac0f1da1909414b1e16bd740c1a3599509), + name: "C-on-A", + tip: Sha1(5f37dbfd4b1c3d2ee75f216665ab4edf44c843cb), review_id: None, is_checked_out: false, }, @@ -94,7 +94,7 @@ mod stacks { is_checked_out: false, }, ], - tip: Sha1(4e5484ac0f1da1909414b1e16bd740c1a3599509), + tip: Sha1(5f37dbfd4b1c3d2ee75f216665ab4edf44c843cb), order: None, is_checked_out: false, }, @@ -111,12 +111,12 @@ mod stacks { [ StackEntry { id: Some( - 00000000-0000-0000-0000-000000000002, + 00000000-0000-0000-0000-000000000001, ), heads: [ StackHeadInfo { - name: "C-on-A", - tip: Sha1(5f37dbfd4b1c3d2ee75f216665ab4edf44c843cb), + name: "B-on-A", + tip: Sha1(4e5484ac0f1da1909414b1e16bd740c1a3599509), review_id: None, is_checked_out: false, }, @@ -127,18 +127,18 @@ mod stacks { is_checked_out: false, }, ], - tip: Sha1(5f37dbfd4b1c3d2ee75f216665ab4edf44c843cb), + tip: Sha1(4e5484ac0f1da1909414b1e16bd740c1a3599509), order: None, is_checked_out: false, }, StackEntry { id: Some( - 00000000-0000-0000-0000-000000000001, + 00000000-0000-0000-0000-000000000002, ), heads: [ StackHeadInfo { - name: "B-on-A", - tip: Sha1(4e5484ac0f1da1909414b1e16bd740c1a3599509), + name: "C-on-A", + tip: Sha1(5f37dbfd4b1c3d2ee75f216665ab4edf44c843cb), review_id: None, is_checked_out: false, }, @@ -149,7 +149,7 @@ mod stacks { is_checked_out: false, }, ], - tip: Sha1(4e5484ac0f1da1909414b1e16bd740c1a3599509), + tip: Sha1(5f37dbfd4b1c3d2ee75f216665ab4edf44c843cb), order: None, is_checked_out: false, }, @@ -171,7 +171,7 @@ mod stacks { [ StackEntry { id: Some( - 00000000-0000-0000-0000-000000000002, + 00000000-0000-0000-0000-000000000001, ), heads: [ StackHeadInfo { @@ -181,7 +181,7 @@ mod stacks { is_checked_out: true, }, ], - tip: Sha1(5f37dbfd4b1c3d2ee75f216665ab4edf44c843cb), + tip: Sha1(4e5484ac0f1da1909414b1e16bd740c1a3599509), order: None, is_checked_out: true, }, @@ -198,19 +198,19 @@ mod stacks { details.to_debug(), snapbox::str![[r#" StackDetails { - derived_name: "C-on-A", + derived_name: "B-on-A", push_status: CompletelyUnpushed, branch_details: [ BranchDetails { - name: "C-on-A", + name: "B-on-A", reference: FullName( - "refs/heads/C-on-A", + "refs/heads/B-on-A", ), linked_worktree_id: None, remote_tracking_branch: None, pr_number: None, review_id: None, - tip: Sha1(5f37dbfd4b1c3d2ee75f216665ab4edf44c843cb), + tip: Sha1(4e5484ac0f1da1909414b1e16bd740c1a3599509), base_commit: Sha1(d79bba960b112dbd25d45921c47eeda22288022b), push_status: CompletelyUnpushed, last_updated_at: None, @@ -219,7 +219,7 @@ StackDetails { ], is_conflicted: false, commits: [ - Commit(5f37dbf, "add new file in C-on-A", local), + Commit(4e5484a, "add new file in B-on-A", local), ], upstream_commits: [], is_remote_head: false, @@ -404,10 +404,10 @@ StackDetails { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* 820f2b3 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* fe1a116 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -| * 4e5484a (B-on-A) add new file in B-on-A -* | 5f37dbf (C-on-A) add new file in C-on-A +| * 5f37dbf (C-on-A) add new file in C-on-A +* | 4e5484a (B-on-A) add new file in B-on-A |/ | * 89cc2d3 (origin/A) change in A |/ @@ -582,15 +582,14 @@ StackDetails { // The raw workspace projection can now link the advanced tip back to `refs/heads/B` even // though the extra commit sits outside the workspace commit. That sibling link records the // outside commit separately from the in-workspace `B` commit. - let graph = but_graph::Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &meta, but_core::ref_metadata::ProjectMeta::default(), - but_graph::init::Options { + but_graph::walk::Options { ..standard_options().traversal }, )?; - let ws = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -610,7 +609,6 @@ StackDetails { ws.to_debug(), snapbox::str![[r#" Workspace(๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“!) { - id: 0, kind: Managed { ref_info: RefInfo { ref_name: FullName( @@ -767,14 +765,6 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(85efbe4d5a663bff0ed8fb5fbc38a72be0592f55), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, diff --git a/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/mod.rs b/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/mod.rs index 2b9b7ee5711..da2d54776bb 100644 --- a/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/mod.rs +++ b/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/mod.rs @@ -262,14 +262,6 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -340,14 +332,6 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), - }, - ), - lower_bound: Some( - NodeIndex(3), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -422,14 +406,6 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), - }, - ), - lower_bound: Some( - NodeIndex(3), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -531,14 +507,6 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -725,14 +693,6 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -836,14 +796,6 @@ RefInfo { commits_ahead: 1, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -944,14 +896,6 @@ RefInfo { commits_ahead: 1, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(a455fe761e758d0b6c0aa8966d91f2de32fa7bfc), - }, - ), - lower_bound: Some( - NodeIndex(4), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -1038,14 +982,6 @@ RefInfo { commits_ahead: 1, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(3), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -1143,14 +1079,6 @@ RefInfo { commits_ahead: 1, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(281456a55524d78e1e0ecab946032423aec1abe8), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -1229,14 +1157,6 @@ RefInfo { commits_ahead: 1, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(281456a55524d78e1e0ecab946032423aec1abe8), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -1333,14 +1253,6 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), - }, - ), - lower_bound: Some( - NodeIndex(3), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -1475,14 +1387,6 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -1642,14 +1546,6 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(3), - ), is_managed_ref: true, is_managed_commit: false, ancestor_workspace_commit: None, @@ -1792,14 +1688,6 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(3), - ), is_managed_ref: true, is_managed_commit: false, ancestor_workspace_commit: None, @@ -1859,18 +1747,16 @@ RefInfo { stacks: [ Stack { id: Some( - 00000000-0000-0000-0000-000000000000, + 00000000-0000-0000-0000-000000000001, ), base: Some( Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), ), segments: [ ref_info::ui::Segment { - ref_name: "โ–บadvanced-lane", + ref_name: "โ–บlane", remote_tracking_ref_name: "None", - commits: [ - LocalCommit(cbc6713, "change\n", local), - ], + commits: [], commits_on_remote: [], commits_outside: None, metadata: Branch, @@ -1881,16 +1767,18 @@ RefInfo { }, Stack { id: Some( - 00000000-0000-0000-0000-000000000001, + 00000000-0000-0000-0000-000000000000, ), base: Some( Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), ), segments: [ ref_info::ui::Segment { - ref_name: "โ–บlane", + ref_name: "โ–บadvanced-lane", remote_tracking_ref_name: "None", - commits: [], + commits: [ + LocalCommit(cbc6713, "change\n", local), + ], commits_on_remote: [], commits_outside: None, metadata: Branch, @@ -1908,14 +1796,6 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -2002,14 +1882,6 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -2114,14 +1986,6 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -2209,14 +2073,6 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -2328,14 +2184,6 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -2431,14 +2279,6 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -2541,14 +2381,6 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -2653,14 +2485,6 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(3), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -2680,10 +2504,14 @@ fn single_commit_but_two_branches_stack_on_top_of_ws_commit() -> anyhow::Result< snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* cbc6713 (HEAD -> gitbutler/workspace, advanced-lane) change +* 335d6f2 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|\ +| * cbc6713 (advanced-lane) change +|/ * fafd9d0 (origin/main, main, lane) init "#]] + .raw() ); for (idx, name) in ["advanced-lane", "lane"].into_iter().enumerate() { @@ -2701,7 +2529,7 @@ RefInfo { "refs/heads/gitbutler/workspace", ), commit_id: Some( - Sha1(cbc6713ccfc78aa9a3c9cf8305a6fadce0bbe1a4), + Sha1(335d6f2a960f387b039bd77476ae3d2d6649ed70), ), worktree: Some( Worktree { @@ -2717,18 +2545,16 @@ RefInfo { stacks: [ Stack { id: Some( - 00000000-0000-0000-0000-000000000000, + 00000000-0000-0000-0000-000000000001, ), base: Some( Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), ), segments: [ ref_info::ui::Segment { - ref_name: "โ–บadvanced-lane", + ref_name: "โ–บlane", remote_tracking_ref_name: "None", - commits: [ - LocalCommit(cbc6713, "change\n", local), - ], + commits: [], commits_on_remote: [], commits_outside: None, metadata: Branch, @@ -2739,16 +2565,18 @@ RefInfo { }, Stack { id: Some( - 00000000-0000-0000-0000-000000000001, + 00000000-0000-0000-0000-000000000000, ), base: Some( Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), ), segments: [ ref_info::ui::Segment { - ref_name: "โ–บlane", + ref_name: "โ–บadvanced-lane", remote_tracking_ref_name: "None", - commits: [], + commits: [ + LocalCommit(cbc6713, "change\n", local), + ], commits_on_remote: [], commits_outside: None, metadata: Branch, @@ -2766,16 +2594,8 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, - is_managed_commit: false, + is_managed_commit: true, ancestor_workspace_commit: None, is_entrypoint: true, } @@ -2800,7 +2620,7 @@ RefInfo { "refs/heads/gitbutler/workspace", ), commit_id: Some( - Sha1(cbc6713ccfc78aa9a3c9cf8305a6fadce0bbe1a4), + Sha1(335d6f2a960f387b039bd77476ae3d2d6649ed70), ), worktree: Some( Worktree { @@ -2816,18 +2636,16 @@ RefInfo { stacks: [ Stack { id: Some( - 00000000-0000-0000-0000-000000000000, + 00000000-0000-0000-0000-000000000001, ), base: Some( Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), ), segments: [ - ๐Ÿ‘‰ref_info::ui::Segment { - ref_name: "โ–บadvanced-lane", + ref_info::ui::Segment { + ref_name: "โ–บlane", remote_tracking_ref_name: "None", - commits: [ - LocalCommit(cbc6713, "change\n", local), - ], + commits: [], commits_on_remote: [], commits_outside: None, metadata: Branch, @@ -2838,16 +2656,18 @@ RefInfo { }, Stack { id: Some( - 00000000-0000-0000-0000-000000000001, + 00000000-0000-0000-0000-000000000000, ), base: Some( Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), ), segments: [ - ref_info::ui::Segment { - ref_name: "โ–บlane", + ๐Ÿ‘‰ref_info::ui::Segment { + ref_name: "โ–บadvanced-lane", remote_tracking_ref_name: "None", - commits: [], + commits: [ + LocalCommit(cbc6713, "change\n", local), + ], commits_on_remote: [], commits_outside: None, metadata: Branch, @@ -2865,16 +2685,8 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(3), - ), is_managed_ref: true, - is_managed_commit: false, + is_managed_commit: true, ancestor_workspace_commit: None, is_entrypoint: false, } @@ -2934,16 +2746,18 @@ RefInfo { stacks: [ Stack { id: Some( - 00000000-0000-0000-0000-000000000000, + 00000000-0000-0000-0000-000000000001, ), base: Some( Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), ), segments: [ ref_info::ui::Segment { - ref_name: "โ–บlane", + ref_name: "โ–บadvanced-lane", remote_tracking_ref_name: "None", - commits: [], + commits: [ + LocalCommit(cbc6713, "change\n", local), + ], commits_on_remote: [], commits_outside: None, metadata: Branch, @@ -2954,18 +2768,16 @@ RefInfo { }, Stack { id: Some( - 00000000-0000-0000-0000-000000000001, + 00000000-0000-0000-0000-000000000000, ), base: Some( Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), ), segments: [ ref_info::ui::Segment { - ref_name: "โ–บadvanced-lane", + ref_name: "โ–บlane", remote_tracking_ref_name: "None", - commits: [ - LocalCommit(cbc6713, "change\n", local), - ], + commits: [], commits_on_remote: [], commits_outside: None, metadata: Branch, @@ -2980,17 +2792,9 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - commits_ahead: 1, - }, - ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), + commits_ahead: 0, }, ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -3031,16 +2835,18 @@ RefInfo { stacks: [ Stack { id: Some( - 00000000-0000-0000-0000-000000000000, + 00000000-0000-0000-0000-000000000001, ), base: Some( Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), ), segments: [ - ref_info::ui::Segment { - ref_name: "โ–บlane", + ๐Ÿ‘‰ref_info::ui::Segment { + ref_name: "โ–บadvanced-lane", remote_tracking_ref_name: "None", - commits: [], + commits: [ + LocalCommit(cbc6713, "change\n", local), + ], commits_on_remote: [], commits_outside: None, metadata: Branch, @@ -3051,18 +2857,16 @@ RefInfo { }, Stack { id: Some( - 00000000-0000-0000-0000-000000000001, + 00000000-0000-0000-0000-000000000000, ), base: Some( Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), ), segments: [ - ๐Ÿ‘‰ref_info::ui::Segment { - ref_name: "โ–บadvanced-lane", + ref_info::ui::Segment { + ref_name: "โ–บlane", remote_tracking_ref_name: "None", - commits: [ - LocalCommit(cbc6713, "change\n", local), - ], + commits: [], commits_on_remote: [], commits_outside: None, metadata: Branch, @@ -3077,17 +2881,9 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - commits_ahead: 1, - }, - ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), + commits_ahead: 0, }, ), - lower_bound: Some( - NodeIndex(3), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -3123,16 +2919,18 @@ RefInfo { stacks: [ Stack { id: Some( - 00000000-0000-0000-0000-000000000000, + 00000000-0000-0000-0000-000000000001, ), base: Some( Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), ), segments: [ - ๐Ÿ‘‰ref_info::ui::Segment { - ref_name: "โ–บlane", + ref_info::ui::Segment { + ref_name: "โ–บadvanced-lane", remote_tracking_ref_name: "None", - commits: [], + commits: [ + LocalCommit(cbc6713, "change\n", local), + ], commits_on_remote: [], commits_outside: None, metadata: Branch, @@ -3143,18 +2941,16 @@ RefInfo { }, Stack { id: Some( - 00000000-0000-0000-0000-000000000001, + 00000000-0000-0000-0000-000000000000, ), base: Some( Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), ), segments: [ - ref_info::ui::Segment { - ref_name: "โ–บadvanced-lane", + ๐Ÿ‘‰ref_info::ui::Segment { + ref_name: "โ–บlane", remote_tracking_ref_name: "None", - commits: [ - LocalCommit(cbc6713, "change\n", local), - ], + commits: [], commits_on_remote: [], commits_outside: None, metadata: Branch, @@ -3169,17 +2965,9 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - commits_ahead: 1, - }, - ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), + commits_ahead: 0, }, ), - lower_bound: Some( - NodeIndex(0), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -3267,17 +3055,9 @@ RefInfo { ref_name: FullName( "refs/remotes/origin/main", ), - commits_ahead: 1, - }, - ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), + commits_ahead: 0, }, ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -3357,14 +3137,6 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - }, - ), - lower_bound: Some( - NodeIndex(0), - ), is_managed_ref: false, is_managed_commit: false, ancestor_workspace_commit: None, @@ -3382,10 +3154,10 @@ fn multiple_branches_with_shared_segment() -> anyhow::Result<()> { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* 820f2b3 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* fe1a116 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -| * 4e5484a (B-on-A) add new file in B-on-A -* | 5f37dbf (C-on-A) add new file in C-on-A +| * 5f37dbf (C-on-A) add new file in C-on-A +* | 4e5484a (B-on-A) add new file in B-on-A |/ | * 89cc2d3 (origin/A) change in A |/ @@ -3411,7 +3183,7 @@ RefInfo { "refs/heads/gitbutler/workspace", ), commit_id: Some( - Sha1(820f2b3c5007e15ba4558556a81d241fcee06856), + Sha1(fe1a116f4ca988026773b0739234a1baaab5649d), ), worktree: Some( Worktree { @@ -3426,22 +3198,20 @@ RefInfo { }, stacks: [ Stack { - id: Some( - 00000000-0000-0000-0000-000000000001, - ), + id: None, base: Some( Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), ), segments: [ ref_info::ui::Segment { - ref_name: "โ–บC-on-A", + ref_name: "โ–บB-on-A", remote_tracking_ref_name: "None", commits: [ - LocalCommit(5f37dbf, "add new file in C-on-A\n", local), + LocalCommit(4e5484a, "add new file in B-on-A\n", local), ], commits_on_remote: [], commits_outside: None, - metadata: Branch, + metadata: "None", push_status: CompletelyUnpushed, base: "d79bba9", }, @@ -3462,20 +3232,22 @@ RefInfo { ], }, Stack { - id: None, + id: Some( + 00000000-0000-0000-0000-000000000001, + ), base: Some( Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), ), segments: [ ref_info::ui::Segment { - ref_name: "โ–บB-on-A", + ref_name: "โ–บC-on-A", remote_tracking_ref_name: "None", commits: [ - LocalCommit(4e5484a, "add new file in B-on-A\n", local), + LocalCommit(5f37dbf, "add new file in C-on-A\n", local), ], commits_on_remote: [], commits_outside: None, - metadata: "None", + metadata: Branch, push_status: CompletelyUnpushed, base: "d79bba9", }, @@ -3504,14 +3276,6 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -3535,7 +3299,7 @@ RefInfo { "refs/heads/gitbutler/workspace", ), commit_id: Some( - Sha1(820f2b3c5007e15ba4558556a81d241fcee06856), + Sha1(fe1a116f4ca988026773b0739234a1baaab5649d), ), worktree: Some( Worktree { @@ -3550,22 +3314,20 @@ RefInfo { }, stacks: [ Stack { - id: Some( - 00000000-0000-0000-0000-000000000001, - ), + id: None, base: Some( Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), ), segments: [ - ๐Ÿ‘‰ref_info::ui::Segment { - ref_name: "โ–บC-on-A", + ref_info::ui::Segment { + ref_name: "โ–บB-on-A", remote_tracking_ref_name: "None", commits: [ - LocalCommit(5f37dbf, "add new file in C-on-A\n", local), + LocalCommit(4e5484a, "add new file in B-on-A\n", local), ], commits_on_remote: [], commits_outside: None, - metadata: Branch, + metadata: "None", push_status: CompletelyUnpushed, base: "d79bba9", }, @@ -3586,20 +3348,22 @@ RefInfo { ], }, Stack { - id: None, + id: Some( + 00000000-0000-0000-0000-000000000001, + ), base: Some( Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), ), segments: [ - ref_info::ui::Segment { - ref_name: "โ–บB-on-A", + ๐Ÿ‘‰ref_info::ui::Segment { + ref_name: "โ–บC-on-A", remote_tracking_ref_name: "None", commits: [ - LocalCommit(4e5484a, "add new file in B-on-A\n", local), + LocalCommit(5f37dbf, "add new file in C-on-A\n", local), ], commits_on_remote: [], commits_outside: None, - metadata: "None", + metadata: Branch, push_status: CompletelyUnpushed, base: "d79bba9", }, @@ -3628,14 +3392,6 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), - }, - ), - lower_bound: Some( - NodeIndex(3), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -3659,7 +3415,7 @@ RefInfo { "refs/heads/gitbutler/workspace", ), commit_id: Some( - Sha1(820f2b3c5007e15ba4558556a81d241fcee06856), + Sha1(fe1a116f4ca988026773b0739234a1baaab5649d), ), worktree: Some( Worktree { @@ -3674,22 +3430,20 @@ RefInfo { }, stacks: [ Stack { - id: Some( - 00000000-0000-0000-0000-000000000001, - ), + id: None, base: Some( Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), ), segments: [ - ref_info::ui::Segment { - ref_name: "โ–บC-on-A", + ๐Ÿ‘‰ref_info::ui::Segment { + ref_name: "โ–บB-on-A", remote_tracking_ref_name: "None", commits: [ - LocalCommit(5f37dbf, "add new file in C-on-A\n", local), + LocalCommit(4e5484a, "add new file in B-on-A\n", local), ], commits_on_remote: [], commits_outside: None, - metadata: Branch, + metadata: "None", push_status: CompletelyUnpushed, base: "d79bba9", }, @@ -3710,20 +3464,22 @@ RefInfo { ], }, Stack { - id: None, + id: Some( + 00000000-0000-0000-0000-000000000001, + ), base: Some( Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), ), segments: [ - ๐Ÿ‘‰ref_info::ui::Segment { - ref_name: "โ–บB-on-A", + ref_info::ui::Segment { + ref_name: "โ–บC-on-A", remote_tracking_ref_name: "None", commits: [ - LocalCommit(4e5484a, "add new file in B-on-A\n", local), + LocalCommit(5f37dbf, "add new file in C-on-A\n", local), ], commits_on_remote: [], commits_outside: None, - metadata: "None", + metadata: Branch, push_status: CompletelyUnpushed, base: "d79bba9", }, @@ -3752,14 +3508,6 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), - }, - ), - lower_bound: Some( - NodeIndex(3), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -3784,7 +3532,7 @@ RefInfo { "refs/heads/gitbutler/workspace", ), commit_id: Some( - Sha1(820f2b3c5007e15ba4558556a81d241fcee06856), + Sha1(fe1a116f4ca988026773b0739234a1baaab5649d), ), worktree: Some( Worktree { @@ -3799,22 +3547,20 @@ RefInfo { }, stacks: [ Stack { - id: Some( - 00000000-0000-0000-0000-000000000001, - ), + id: None, base: Some( Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), ), segments: [ ref_info::ui::Segment { - ref_name: "โ–บC-on-A", + ref_name: "โ–บB-on-A", remote_tracking_ref_name: "None", commits: [ - LocalCommit(5f37dbf, "add new file in C-on-A\n", local), + LocalCommit(4e5484a, "add new file in B-on-A\n", local), ], commits_on_remote: [], commits_outside: None, - metadata: Branch, + metadata: "None", push_status: CompletelyUnpushed, base: "d79bba9", }, @@ -3835,20 +3581,22 @@ RefInfo { ], }, Stack { - id: None, + id: Some( + 00000000-0000-0000-0000-000000000001, + ), base: Some( Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), ), segments: [ ref_info::ui::Segment { - ref_name: "โ–บB-on-A", + ref_name: "โ–บC-on-A", remote_tracking_ref_name: "None", commits: [ - LocalCommit(4e5484a, "add new file in B-on-A\n", local), + LocalCommit(5f37dbf, "add new file in C-on-A\n", local), ], commits_on_remote: [], commits_outside: None, - metadata: "None", + metadata: Branch, push_status: CompletelyUnpushed, base: "d79bba9", }, @@ -3877,14 +3625,6 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), - }, - ), - lower_bound: Some( - NodeIndex(3), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -3966,14 +3706,6 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -4038,14 +3770,6 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), - }, - ), - lower_bound: Some( - NodeIndex(0), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -4092,14 +3816,6 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), - }, - ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -4119,37 +3835,23 @@ RefInfo { workspace_ref_info: Some( RefInfo { ref_name: FullName( - "refs/heads/unrelated", + "refs/heads/gitbutler/workspace", ), commit_id: Some( - Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), + Sha1(c7276fa4ef234bed041b4293f46615a99afc7f50), + ), + worktree: Some( + Worktree { + kind: Main, + owned_by_repo: true, + }, ), - worktree: None, }, ), symbolic_remote_names: { "origin", }, - stacks: [ - Stack { - id: Some( - 00000000-0000-0000-0000-000000000001, - ), - base: None, - segments: [ - ref_info::ui::Segment { - ref_name: "โ–บunrelated", - remote_tracking_ref_name: "None", - commits: [], - commits_on_remote: [], - commits_outside: None, - metadata: Branch, - push_status: CompletelyUnpushed, - base: "None", - }, - ], - }, - ], + stacks: [], target_ref: Some( TargetRef { ref_name: FullName( @@ -4158,16 +3860,10 @@ RefInfo { commits_ahead: 0, }, ), - target_commit: Some( - TargetCommit { - commit_id: Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), - }, - ), - lower_bound: None, - is_managed_ref: false, - is_managed_commit: false, + is_managed_ref: true, + is_managed_commit: true, ancestor_workspace_commit: None, - is_entrypoint: true, + is_entrypoint: false, } "#]] @@ -4244,7 +3940,7 @@ fn advanced_workspace_single_stack() -> anyhow::Result<()> { snapbox::assert_data_eq!( err.to_string(), snapbox::str![[r#" -Found 4 commit(s) on top of the workspace commit. +Found 5 commit(s) on top of the workspace commit. Run the following command in your working directory to fix this while leaving your worktree unchanged. Worktree changes need to be re-committed manually for now. @@ -4261,7 +3957,7 @@ mod legacy; pub(crate) mod utils { use but_core::{RefMetadata, ref_metadata::StackId}; - use but_graph::init::Options; + use but_graph::walk::Options; use but_meta::{ VirtualBranchesTomlMetadata, virtual_branches_legacy_types::{Stack, StackBranch, Target}, @@ -4341,7 +4037,7 @@ pub(crate) mod utils { init_meta: impl FnMut(&mut VirtualBranchesTomlMetadata), ) -> anyhow::Result<( TempDir, - but_graph::Graph, + but_graph::Workspace, gix::Repository, VirtualBranchesTomlMetadata, String, @@ -4385,7 +4081,7 @@ pub(crate) mod utils { mut init_meta: impl FnMut(&mut VirtualBranchesTomlMetadata), ) -> anyhow::Result<( TempDir, - but_graph::Graph, + but_graph::Workspace, gix::Repository, VirtualBranchesTomlMetadata, String, @@ -4401,7 +4097,7 @@ pub(crate) mod utils { .expect("valid workspace ref"), )? .project_meta(); - let graph = but_graph::Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &meta, project_meta, @@ -4410,7 +4106,7 @@ pub(crate) mod utils { ..Options::limited() }, )?; - Ok((tmp, graph, repo, meta, desc)) + Ok((tmp, ws, repo, meta, desc)) } pub fn named_writable_scenario( diff --git a/crates/but-workspace/tests/workspace/ui.rs b/crates/but-workspace/tests/workspace/ui.rs index 4ed08ade7bd..64992020d25 100644 --- a/crates/but-workspace/tests/workspace/ui.rs +++ b/crates/but-workspace/tests/workspace/ui.rs @@ -1,6 +1,6 @@ mod changes_in_branch { use but_core::RefMetadata; - use but_graph::init::Options; + use but_graph::walk::Options; use but_testsupport::visualize_commit_graph_all; use but_workspace::ui; use snapbox::prelude::*; @@ -22,14 +22,13 @@ mod changes_in_branch { "#]] ); - let graph = but_graph::Graph::from_head( + let ws = but_graph::Workspace::from_head( &repo, &*meta, meta.workspace(but_core::WORKSPACE_REF_NAME.try_into()?)? .project_meta(), Options::limited(), )?; - let ws = graph.into_workspace()?; snapbox::assert_data_eq!( ui::diff::changes_in_branch(&repo, &ws, r("refs/heads/A"))?.to_debug(), diff --git a/crates/but-workspace/tests/workspace/upstream_integration.rs b/crates/but-workspace/tests/workspace/upstream_integration.rs index e52cd0e5c13..5bd56757081 100644 --- a/crates/but-workspace/tests/workspace/upstream_integration.rs +++ b/crates/but-workspace/tests/workspace/upstream_integration.rs @@ -1,9 +1,9 @@ use anyhow::{Context, Result}; use bstr::ByteSlice; use but_core::{Commit, RefMetadata}; -use but_graph::init::{Options, Tip}; +use but_graph::walk::{Options, Seed}; use but_meta::virtual_branches_legacy_types::Target; -use but_rebase::graph_rebase::mutate::RelativeTo; +use but_rebase::graph_rebase::selector::RelativeTo; use but_testsupport::{CommandExt, git, graph_workspace, visualize_commit_graph_all}; use but_workspace::{ BottomUpdate, BottomUpdateKind, ReviewIntegrationHint, integrate_upstream, @@ -11,7 +11,7 @@ use but_workspace::{ }; use gix::prelude::ObjectIdExt; use gix::refs::transaction::PreviousValue; -use snapbox::IntoData; +use snapbox::prelude::*; use crate::ref_info::with_workspace_commit::utils::{ StackState, add_stack, add_stack_with_segments, named_writable_scenario_with_description, @@ -36,7 +36,7 @@ fn diamond_partially_historically_integrated_rebase() -> Result<()> { push_remote_name: None, }); add_stack(&mut meta, 1, "E", StackState::InWorkspace); - let graph = but_graph::Graph::from_head( + let workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -70,11 +70,10 @@ fn diamond_partially_historically_integrated_rebase() -> Result<()> { .raw() ); - let mut workspace = graph.into_workspace()?; let remote_tip_before = repo.rev_parse_single("origin/master")?.detach(); - let project_meta = workspace.graph.project_meta.clone(); + let project_meta = workspace.project_meta().clone(); let but_workspace::IntegrateUpstreamOutcome { rebase, .. } = integrate_upstream( - &mut workspace, + &workspace, &mut meta, project_meta, &repo, @@ -129,7 +128,7 @@ fn diamond_partially_historically_integrated_merge() -> Result<()> { push_remote_name: None, }); add_stack(&mut meta, 1, "E", StackState::InWorkspace); - let graph = but_graph::Graph::from_head( + let workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -163,10 +162,9 @@ fn diamond_partially_historically_integrated_merge() -> Result<()> { .raw() ); - let mut workspace = graph.into_workspace()?; - let project_meta = workspace.graph.project_meta.clone(); + let project_meta = workspace.project_meta().clone(); let but_workspace::IntegrateUpstreamOutcome { rebase, .. } = integrate_upstream( - &mut workspace, + &workspace, &mut meta, project_meta, &repo, @@ -220,7 +218,7 @@ fn diamond_partially_content_integrated_rebase() -> Result<()> { push_remote_name: None, }); add_stack(&mut meta, 1, "E", StackState::InWorkspace); - let graph = but_graph::Graph::from_head( + let workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -252,7 +250,6 @@ fn diamond_partially_content_integrated_rebase() -> Result<()> { .raw() ); - let mut workspace = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -269,9 +266,9 @@ fn diamond_partially_content_integrated_rebase() -> Result<()> { "#]] ); - let project_meta = workspace.graph.project_meta.clone(); + let project_meta = workspace.project_meta().clone(); let but_workspace::IntegrateUpstreamOutcome { rebase, .. } = integrate_upstream( - &mut workspace, + &workspace, &mut meta, project_meta, &repo, @@ -318,7 +315,7 @@ fn diamond_partially_content_integrated_merge() -> Result<()> { push_remote_name: None, }); add_stack(&mut meta, 1, "E", StackState::InWorkspace); - let graph = but_graph::Graph::from_head( + let workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -350,10 +347,9 @@ fn diamond_partially_content_integrated_merge() -> Result<()> { .raw() ); - let mut workspace = graph.into_workspace()?; - let project_meta = workspace.graph.project_meta.clone(); + let project_meta = workspace.project_meta().clone(); let but_workspace::IntegrateUpstreamOutcome { rebase, .. } = integrate_upstream( - &mut workspace, + &workspace, &mut meta, project_meta, &repo, @@ -406,11 +402,11 @@ fn integrated_bottom_branch_no_workspace_rebase() -> Result<()> { sha: target_sha, push_remote_name: None, }); - let graph = but_graph::Graph::from_commit_traversal_tips( + let workspace = but_graph::Workspace::from_seeds( &repo, [ - Tip::entrypoint(repo.head_id()?.detach(), Some("refs/heads/A".try_into()?)), - Tip::integrated( + Seed::entrypoint(repo.head_id()?.detach(), Some("refs/heads/A".try_into()?)), + Seed::integrated( repo.rev_parse_single("origin/main")?.detach(), Some("refs/remotes/origin/main".try_into()?), ), @@ -435,7 +431,6 @@ fn integrated_bottom_branch_no_workspace_rebase() -> Result<()> { "#]] ); - let mut workspace = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -448,9 +443,9 @@ fn integrated_bottom_branch_no_workspace_rebase() -> Result<()> { "#]] ); - let project_meta = workspace.graph.project_meta.clone(); + let project_meta = workspace.project_meta().clone(); let but_workspace::IntegrateUpstreamOutcome { rebase, .. } = integrate_upstream( - &mut workspace, + &workspace, &mut meta, project_meta, &repo, @@ -502,11 +497,11 @@ fn integrated_bottom_branch_does_not_delete_local_main_or_master() -> Result<()> sha: target_sha, push_remote_name: None, }); - let graph = but_graph::Graph::from_commit_traversal_tips( + let workspace = but_graph::Workspace::from_seeds( &repo, [ - Tip::entrypoint(repo.head_id()?.detach(), Some("refs/heads/A".try_into()?)), - Tip::integrated( + Seed::entrypoint(repo.head_id()?.detach(), Some("refs/heads/A".try_into()?)), + Seed::integrated( repo.rev_parse_single("origin/main")?.detach(), Some("refs/remotes/origin/main".try_into()?), ), @@ -518,11 +513,10 @@ fn integrated_bottom_branch_does_not_delete_local_main_or_master() -> Result<()> ..Options::limited() }, )?; - let mut workspace = graph.into_workspace()?; - let project_meta = workspace.graph.project_meta.clone(); + let project_meta = workspace.project_meta().clone(); let but_workspace::IntegrateUpstreamOutcome { rebase, .. } = integrate_upstream( - &mut workspace, + &workspace, &mut meta, project_meta, &repo, @@ -565,11 +559,11 @@ fn integrated_bottom_branch_no_workspace_merge() -> Result<()> { sha: target_sha, push_remote_name: None, }); - let graph = but_graph::Graph::from_commit_traversal_tips( + let workspace = but_graph::Workspace::from_seeds( &repo, [ - Tip::entrypoint(repo.head_id()?.detach(), Some("refs/heads/A".try_into()?)), - Tip::integrated( + Seed::entrypoint(repo.head_id()?.detach(), Some("refs/heads/A".try_into()?)), + Seed::integrated( repo.rev_parse_single("origin/main")?.detach(), Some("refs/remotes/origin/main".try_into()?), ), @@ -594,7 +588,6 @@ fn integrated_bottom_branch_no_workspace_merge() -> Result<()> { "#]] ); - let mut workspace = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -607,9 +600,9 @@ fn integrated_bottom_branch_no_workspace_merge() -> Result<()> { "#]] ); - let project_meta = workspace.graph.project_meta.clone(); + let project_meta = workspace.project_meta().clone(); let but_workspace::IntegrateUpstreamOutcome { rebase, .. } = integrate_upstream( - &mut workspace, + &workspace, &mut meta, project_meta, &repo, @@ -670,7 +663,7 @@ fn merge_upstream_with_conflicting_target_materializes_conflicted_merge_commit() push_remote_name: None, }); add_stack(&mut meta, 1, "A", StackState::InWorkspace); - let graph = but_graph::Graph::from_head( + let workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -692,10 +685,9 @@ fn merge_upstream_with_conflicting_target_materializes_conflicted_merge_commit() "#]] ); - let mut workspace = graph.into_workspace()?; - let project_meta = workspace.graph.project_meta.clone(); + let project_meta = workspace.project_meta().clone(); let but_workspace::IntegrateUpstreamOutcome { rebase, .. } = integrate_upstream( - &mut workspace, + &workspace, &mut meta, project_meta, &repo, @@ -775,7 +767,7 @@ fn fully_historically_integrated_branch_leaves_workspace_shape() -> Result<()> { }); add_stack(&mut meta, 1, "A", StackState::InWorkspace); add_stack(&mut meta, 2, "B", StackState::InWorkspace); - let graph = but_graph::Graph::from_head( + let mut workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -788,10 +780,10 @@ fn fully_historically_integrated_branch_leaves_workspace_shape() -> Result<()> { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* 9d7da88 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 0336d9c (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -| * 905d6e5 (origin/main, A) add A1 -* | b38b04b (B) add B1 +| * b38b04b (B) add B1 +* | 905d6e5 (origin/main, A) add A1 |/ * 3183e43 (main) M1 @@ -799,7 +791,6 @@ fn fully_historically_integrated_branch_leaves_workspace_shape() -> Result<()> { .raw() ); - let mut workspace = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -830,9 +821,8 @@ fn fully_historically_integrated_branch_leaves_workspace_shape() -> Result<()> { ], )?; - let graph = - but_graph::Graph::from_head(&repo, &meta, project_meta(&meta)?, Options::limited())?; - let workspace = graph.into_workspace()?; + let workspace = + but_graph::Workspace::from_head(&repo, &meta, project_meta(&meta)?, Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -846,7 +836,7 @@ fn fully_historically_integrated_branch_leaves_workspace_shape() -> Result<()> { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* eaf66d4 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* c0c03ce (HEAD -> gitbutler/workspace) GitButler Workspace Commit * c932222 (B) add B1 * 905d6e5 (origin/main) add A1 * 3183e43 (main) M1 @@ -870,7 +860,7 @@ fn fully_integrated_single_branch_leaves_workspace_shape() -> Result<()> { push_remote_name: None, }); add_stack(&mut meta, 1, "A", StackState::InWorkspace); - let graph = but_graph::Graph::from_head( + let mut workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -890,7 +880,6 @@ fn fully_integrated_single_branch_leaves_workspace_shape() -> Result<()> { "#]] ); - let mut workspace = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -912,9 +901,8 @@ fn fully_integrated_single_branch_leaves_workspace_shape() -> Result<()> { }], )?; - let graph = - but_graph::Graph::from_head(&repo, &meta, project_meta(&meta)?, Options::limited())?; - let workspace = graph.into_workspace()?; + let workspace = + but_graph::Workspace::from_head(&repo, &meta, project_meta(&meta)?, Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -948,7 +936,7 @@ fn fully_integrated_single_branch_reparents_workspace_commit_to_advanced_target( push_remote_name: None, }); add_stack(&mut meta, 1, "A", StackState::InWorkspace); - let graph = but_graph::Graph::from_head( + let mut workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -971,7 +959,6 @@ fn fully_integrated_single_branch_reparents_workspace_commit_to_advanced_target( "#]] ); - let mut workspace = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -994,9 +981,8 @@ fn fully_integrated_single_branch_reparents_workspace_commit_to_advanced_target( }], )?; - let graph = - but_graph::Graph::from_head(&repo, &meta, project_meta(&meta)?, Options::limited())?; - let workspace = graph.into_workspace()?; + let workspace = + but_graph::Workspace::from_head(&repo, &meta, project_meta(&meta)?, Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -1032,7 +1018,7 @@ fn non_bottom_update_selector_does_not_prune_fully_integrated_stack() -> Result< push_remote_name: None, }); add_stack(&mut meta, 1, "A", StackState::InWorkspace); - let graph = but_graph::Graph::from_head( + let workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -1041,11 +1027,9 @@ fn non_bottom_update_selector_does_not_prune_fully_integrated_stack() -> Result< ..Options::limited() }, )?; - - let mut workspace = graph.into_workspace()?; let project_meta = project_meta(&meta)?; let out = integrate_upstream( - &mut workspace, + &workspace, &mut meta, project_meta.clone(), &repo, @@ -1069,8 +1053,8 @@ fn non_bottom_update_selector_does_not_prune_fully_integrated_stack() -> Result< repo.try_find_reference("A")?.is_some(), "local branch should remain when update selector is not a stack bottom" ); - let graph = but_graph::Graph::from_head(&repo, &meta, project_meta, Options::limited())?; - let workspace = graph.into_workspace()?; + let workspace = + but_graph::Workspace::from_head(&repo, &meta, project_meta, Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -1101,7 +1085,7 @@ fn fully_integrated_single_branch_reparents_workspace_commit_to_advanced_merge_t push_remote_name: None, }); add_stack(&mut meta, 1, "A", StackState::InWorkspace); - let graph = but_graph::Graph::from_head( + let mut workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -1129,7 +1113,6 @@ fn fully_integrated_single_branch_reparents_workspace_commit_to_advanced_merge_t .raw() ); - let mut workspace = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -1152,9 +1135,8 @@ fn fully_integrated_single_branch_reparents_workspace_commit_to_advanced_merge_t }], )?; - let graph = - but_graph::Graph::from_head(&repo, &meta, project_meta(&meta)?, Options::limited())?; - let workspace = graph.into_workspace()?; + let workspace = + but_graph::Workspace::from_head(&repo, &meta, project_meta(&meta)?, Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -1205,7 +1187,7 @@ fn fully_integrated_direct_checkout_creates_unique_canned_branch_at_target_tip() sha: target_sha, push_remote_name: None, }); - let graph = but_graph::Graph::from_head( + let workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -1214,11 +1196,10 @@ fn fully_integrated_direct_checkout_creates_unique_canned_branch_at_target_tip() ..Options::limited() }, )?; - let mut workspace = graph.into_workspace()?; - let project_meta = workspace.graph.project_meta.clone(); + let project_meta = workspace.project_meta().clone(); let but_workspace::IntegrateUpstreamOutcome { rebase, .. } = integrate_upstream( - &mut workspace, + &workspace, &mut meta, project_meta, &repo, @@ -1228,7 +1209,7 @@ fn fully_integrated_direct_checkout_creates_unique_canned_branch_at_target_tip() }], )?; - let preview = rebase.overlayed_graph()?.into_workspace()?; + let preview = but_workspace::workspace::overlayed_workspace(&workspace, &rebase)?; assert_eq!( preview.ref_name(), Some(branch_2.as_ref()), @@ -1301,7 +1282,7 @@ fn fully_integrated_direct_checkout_creates_canned_branch_at_merge_target_tip() sha: target_sha, push_remote_name: None, }); - let graph = but_graph::Graph::from_head( + let workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -1310,11 +1291,10 @@ fn fully_integrated_direct_checkout_creates_canned_branch_at_merge_target_tip() ..Options::limited() }, )?; - let mut workspace = graph.into_workspace()?; - let project_meta = workspace.graph.project_meta.clone(); + let project_meta = workspace.project_meta().clone(); let but_workspace::IntegrateUpstreamOutcome { rebase, .. } = integrate_upstream( - &mut workspace, + &workspace, &mut meta, project_meta, &repo, @@ -1324,7 +1304,7 @@ fn fully_integrated_direct_checkout_creates_canned_branch_at_merge_target_tip() }], )?; - let preview = rebase.overlayed_graph()?.into_workspace()?; + let preview = but_workspace::workspace::overlayed_workspace(&workspace, &rebase)?; assert_eq!( preview.ref_name(), Some(branch_2.as_ref()), @@ -1364,7 +1344,7 @@ fn empty_workspace_reparents_workspace_commit_to_advanced_target() -> Result<()> sha: target_sha, push_remote_name: None, }); - let graph = but_graph::Graph::from_head( + let mut workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -1373,7 +1353,6 @@ fn empty_workspace_reparents_workspace_commit_to_advanced_target() -> Result<()> ..Options::limited() }, )?; - let mut workspace = graph.into_workspace()?; integrate_and_materialize(&mut workspace, &mut meta, &repo, vec![])?; assert_eq!( @@ -1397,7 +1376,7 @@ fn empty_workspace_reparents_workspace_commit_to_merge_advanced_target() -> Resu sha: target_sha, push_remote_name: None, }); - let graph = but_graph::Graph::from_head( + let mut workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -1406,7 +1385,6 @@ fn empty_workspace_reparents_workspace_commit_to_merge_advanced_target() -> Resu ..Options::limited() }, )?; - let mut workspace = graph.into_workspace()?; integrate_and_materialize(&mut workspace, &mut meta, &repo, vec![])?; assert_eq!( @@ -1434,7 +1412,7 @@ fn workspace_target_parent_updates_while_stack_parent_remains_anonymous_segment_ push_remote_name: None, }); add_stack(&mut meta, 1, "A", StackState::InWorkspace); - let graph = but_graph::Graph::from_head( + let mut workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -1459,7 +1437,6 @@ fn workspace_target_parent_updates_while_stack_parent_remains_anonymous_segment_ .raw() ); - let mut workspace = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -1483,9 +1460,8 @@ fn workspace_target_parent_updates_while_stack_parent_remains_anonymous_segment_ }], )?; - let graph = - but_graph::Graph::from_head(&repo, &meta, project_meta(&meta)?, Options::limited())?; - let workspace = graph.into_workspace()?; + let workspace = + but_graph::Workspace::from_head(&repo, &meta, project_meta(&meta)?, Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -1535,7 +1511,7 @@ fn dry_run_reports_dirty_worktree_conflicts_against_resulting_workspace_head() - push_remote_name: None, }); add_stack(&mut meta, 1, "A", StackState::InWorkspace); - let graph = but_graph::Graph::from_head( + let workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -1544,12 +1520,11 @@ fn dry_run_reports_dirty_worktree_conflicts_against_resulting_workspace_head() - ..Options::limited() }, )?; - let mut workspace = graph.into_workspace()?; std::fs::write(tmp.path().join("shared.txt"), "dirty\n")?; let project_meta = project_meta(&meta)?; let but_workspace::IntegrateUpstreamOutcome { rebase, .. } = integrate_upstream( - &mut workspace, + &workspace, &mut meta, project_meta, &repo, @@ -1559,7 +1534,7 @@ fn dry_run_reports_dirty_worktree_conflicts_against_resulting_workspace_head() - }], )?; - let conflicts = worktree_conflicts_for_rebase(&rebase)?; + let conflicts = worktree_conflicts_for_rebase(&workspace, &rebase)?; assert_eq!( conflicts, vec![but_serde::BStringForFrontend::from("shared.txt")], @@ -1590,7 +1565,7 @@ fn dry_run_reports_index_only_conflicts_against_resulting_workspace_head() -> Re push_remote_name: None, }); add_stack(&mut meta, 1, "A", StackState::InWorkspace); - let graph = but_graph::Graph::from_head( + let workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -1599,7 +1574,6 @@ fn dry_run_reports_index_only_conflicts_against_resulting_workspace_head() -> Re ..Options::limited() }, )?; - let mut workspace = graph.into_workspace()?; std::fs::write(tmp.path().join("shared.txt"), "dirty\n")?; git(&repo).args(["add", "shared.txt"]).run(); @@ -1607,7 +1581,7 @@ fn dry_run_reports_index_only_conflicts_against_resulting_workspace_head() -> Re let project_meta = project_meta(&meta)?; let but_workspace::IntegrateUpstreamOutcome { rebase, .. } = integrate_upstream( - &mut workspace, + &workspace, &mut meta, project_meta, &repo, @@ -1617,7 +1591,7 @@ fn dry_run_reports_index_only_conflicts_against_resulting_workspace_head() -> Re }], )?; - let conflicts = worktree_conflicts_for_rebase(&rebase)?; + let conflicts = worktree_conflicts_for_rebase(&workspace, &rebase)?; assert_eq!( conflicts, vec![but_serde::BStringForFrontend::from("shared.txt")], @@ -1641,7 +1615,7 @@ fn partially_integrated_branch_leaves_multi_branch_stack() -> Result<()> { }); add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &["C"]); add_stack(&mut meta, 2, "B", StackState::InWorkspace); - let graph = but_graph::Graph::from_head( + let mut workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -1654,11 +1628,11 @@ fn partially_integrated_branch_leaves_multi_branch_stack() -> Result<()> { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* cf53402 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 236546e (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -| * 44c9428 (A) add A1 -| * f1e7451 (origin/main, C) add C1 -* | b38b04b (B) add B1 +| * b38b04b (B) add B1 +* | 44c9428 (A) add A1 +* | f1e7451 (origin/main, C) add C1 |/ * 3183e43 (main) M1 @@ -1666,7 +1640,6 @@ fn partially_integrated_branch_leaves_multi_branch_stack() -> Result<()> { .raw() ); - let mut workspace = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -1699,9 +1672,8 @@ fn partially_integrated_branch_leaves_multi_branch_stack() -> Result<()> { ], )?; - let graph = - but_graph::Graph::from_head(&repo, &meta, project_meta(&meta)?, Options::limited())?; - let workspace = graph.into_workspace()?; + let workspace = + but_graph::Workspace::from_head(&repo, &meta, project_meta(&meta)?, Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -1718,10 +1690,10 @@ fn partially_integrated_branch_leaves_multi_branch_stack() -> Result<()> { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* 780946b (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* e5c6c38 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -| * 44c9428 (A) add A1 -* | a27415e (B) add B1 +| * a27415e (B) add B1 +* | 44c9428 (A) add A1 |/ * f1e7451 (origin/main) add C1 * 3183e43 (main) M1 @@ -1747,7 +1719,7 @@ fn fully_integrated_multi_branch_stack_leaves_workspace_shape() -> Result<()> { }); add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &["C"]); add_stack(&mut meta, 2, "B", StackState::InWorkspace); - let graph = but_graph::Graph::from_head( + let mut workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -1760,11 +1732,11 @@ fn fully_integrated_multi_branch_stack_leaves_workspace_shape() -> Result<()> { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* cf53402 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 236546e (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -| * 44c9428 (origin/main, A) add A1 -| * f1e7451 (C) add C1 -* | b38b04b (B) add B1 +| * b38b04b (B) add B1 +* | 44c9428 (origin/main, A) add A1 +* | f1e7451 (C) add C1 |/ * 3183e43 (main) M1 @@ -1772,7 +1744,6 @@ fn fully_integrated_multi_branch_stack_leaves_workspace_shape() -> Result<()> { .raw() ); - let mut workspace = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -1805,9 +1776,8 @@ fn fully_integrated_multi_branch_stack_leaves_workspace_shape() -> Result<()> { ], )?; - let graph = - but_graph::Graph::from_head(&repo, &meta, project_meta(&meta)?, Options::limited())?; - let workspace = graph.into_workspace()?; + let workspace = + but_graph::Workspace::from_head(&repo, &meta, project_meta(&meta)?, Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -1821,7 +1791,7 @@ fn fully_integrated_multi_branch_stack_leaves_workspace_shape() -> Result<()> { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* 55ce8ae (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 64f25fa (HEAD -> gitbutler/workspace) GitButler Workspace Commit * f59d71f (B) add B1 * 44c9428 (origin/main) add A1 * f1e7451 add C1 @@ -1847,7 +1817,7 @@ fn fully_integrated_two_stacks_leave_workspace_shape() -> Result<()> { }); add_stack(&mut meta, 1, "A", StackState::InWorkspace); add_stack(&mut meta, 2, "B", StackState::InWorkspace); - let graph = but_graph::Graph::from_head( + let mut workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -1860,17 +1830,18 @@ fn fully_integrated_two_stacks_leave_workspace_shape() -> Result<()> { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* 9d7da88 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 0336d9c (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ | | * 5f7d45e (origin/main, main) Merging B into base | | |\ -| |_|/ -|/| | -* | | b38b04b (B) add B1 +| | |/ +| |/| +| * | b38b04b (B) add B1 | | * 1f7670a Merging A into base | |/| -|/|/ -| * 905d6e5 (A) add A1 +| |/ +|/| +* | 905d6e5 (A) add A1 |/ * 3183e43 M1 @@ -1878,7 +1849,6 @@ fn fully_integrated_two_stacks_leave_workspace_shape() -> Result<()> { .raw() ); - let mut workspace = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -1909,9 +1879,8 @@ fn fully_integrated_two_stacks_leave_workspace_shape() -> Result<()> { ], )?; - let graph = - but_graph::Graph::from_head(&repo, &meta, project_meta(&meta)?, Options::limited())?; - let workspace = graph.into_workspace()?; + let workspace = + but_graph::Workspace::from_head(&repo, &meta, project_meta(&meta)?, Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -1922,7 +1891,7 @@ fn fully_integrated_two_stacks_leave_workspace_shape() -> Result<()> { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* b44fd24 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* ef6d1ef (HEAD -> gitbutler/workspace) GitButler Workspace Commit * 5f7d45e (origin/main, main) Merging B into base |\ | * b38b04b add B1 @@ -1955,7 +1924,7 @@ fn orphan_reparent_content_integrated_stack_to_target_tip() -> Result<()> { push_remote_name: None, }); add_stack(&mut meta, 1, "A", StackState::InWorkspace); - let graph = but_graph::Graph::from_head( + let mut workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -1964,7 +1933,6 @@ fn orphan_reparent_content_integrated_stack_to_target_tip() -> Result<()> { ..Options::limited() }, )?; - let mut workspace = graph.into_workspace()?; integrate_and_materialize( &mut workspace, @@ -2000,7 +1968,7 @@ fn content_integrated_stack_does_not_reparent_while_stack_parent_remains() -> Re }); add_stack(&mut meta, 1, "A", StackState::InWorkspace); add_stack(&mut meta, 2, "B", StackState::InWorkspace); - let graph = but_graph::Graph::from_head( + let mut workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -2009,7 +1977,6 @@ fn content_integrated_stack_does_not_reparent_while_stack_parent_remains() -> Re ..Options::limited() }, )?; - let mut workspace = graph.into_workspace()?; integrate_and_materialize( &mut workspace, @@ -2044,7 +2011,7 @@ fn orphan_reparent_does_not_run_when_parent_remains() -> Result<()> { }); add_stack(&mut meta, 1, "A", StackState::InWorkspace); add_stack(&mut meta, 2, "B", StackState::InWorkspace); - let graph = but_graph::Graph::from_head( + let mut workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -2053,7 +2020,6 @@ fn orphan_reparent_does_not_run_when_parent_remains() -> Result<()> { ..Options::limited() }, )?; - let mut workspace = graph.into_workspace()?; integrate_and_materialize( &mut workspace, @@ -2092,7 +2058,7 @@ fn orphan_reparent_empty_stack_to_target_tip() -> Result<()> { push_remote_name: None, }); add_stack(&mut meta, 1, "B", StackState::InWorkspace); - let graph = but_graph::Graph::from_head( + let mut workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -2101,7 +2067,6 @@ fn orphan_reparent_empty_stack_to_target_tip() -> Result<()> { ..Options::limited() }, )?; - let mut workspace = graph.into_workspace()?; integrate_and_materialize( &mut workspace, @@ -2135,7 +2100,7 @@ fn empty_branch_with_integrated_remote_tip_is_removed() -> Result<()> { push_remote_name: None, }); add_stack(&mut meta, 1, "topic", StackState::InWorkspace); - let graph = but_graph::Graph::from_head( + let workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -2160,7 +2125,6 @@ fn empty_branch_with_integrated_remote_tip_is_removed() -> Result<()> { .raw() ); - let mut workspace = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -2176,7 +2140,7 @@ fn empty_branch_with_integrated_remote_tip_is_removed() -> Result<()> { let project_meta = project_meta(&meta)?; let out = integrate_upstream( - &mut workspace, + &workspace, &mut meta, project_meta.clone(), &repo, @@ -2194,8 +2158,8 @@ fn empty_branch_with_integrated_remote_tip_is_removed() -> Result<()> { "workspace metadata should no longer expose the integrated empty branch" ); out.rebase.materialize()?; - let graph = but_graph::Graph::from_head(&repo, &meta, project_meta, Options::limited())?; - let workspace = graph.into_workspace()?; + let workspace = + but_graph::Workspace::from_head(&repo, &meta, project_meta, Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -2236,7 +2200,7 @@ fn non_empty_branch_with_integrated_remote_tip_keeps_local_work() -> Result<()> push_remote_name: None, }); add_stack(&mut meta, 1, "topic", StackState::InWorkspace); - let graph = but_graph::Graph::from_head( + let mut workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -2263,7 +2227,6 @@ fn non_empty_branch_with_integrated_remote_tip_keeps_local_work() -> Result<()> .raw() ); - let mut workspace = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -2298,8 +2261,8 @@ fn non_empty_branch_with_integrated_remote_tip_keeps_local_work() -> Result<()> .trim_end(), "add local", ); - let graph = but_graph::Graph::from_head(&repo, &meta, project_meta, Options::limited())?; - let workspace = graph.into_workspace()?; + let workspace = + but_graph::Workspace::from_head(&repo, &meta, project_meta, Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -2343,7 +2306,7 @@ fn empty_branch_above_integrated_branch_is_preserved() -> Result<()> { push_remote_name: None, }); add_stack_with_segments(&mut meta, 1, "top", StackState::InWorkspace, &["bottom"]); - let graph = but_graph::Graph::from_head( + let workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -2368,7 +2331,6 @@ fn empty_branch_above_integrated_branch_is_preserved() -> Result<()> { .raw() ); - let mut workspace = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -2384,7 +2346,7 @@ fn empty_branch_above_integrated_branch_is_preserved() -> Result<()> { let bottom_bottom_commit = repo.rev_parse_single("bottom")?.object()?.id; let project_meta = project_meta(&meta)?; let out = integrate_upstream( - &mut workspace, + &workspace, &mut meta, project_meta.clone(), &repo, @@ -2411,15 +2373,15 @@ fn empty_branch_above_integrated_branch_is_preserved() -> Result<()> { "workspace metadata should retain only the unmerged empty top branch" ); out.rebase.materialize()?; - let graph = but_graph::Graph::from_head(&repo, &meta, project_meta, Options::limited())?; - let workspace = graph.into_workspace()?; + let workspace = + but_graph::Workspace::from_head(&repo, &meta, project_meta, Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 563a7fc โ””โ”€โ”€ โ‰ก๐Ÿ“™:top <> origin/top on 563a7fc {1} โ””โ”€โ”€ ๐Ÿ“™:top <> origin/top - โ””โ”€โ”€ ยท334227d (๐Ÿ˜๏ธ|โœ“) + โ””โ”€โ”€ ยท334227d (๐Ÿ˜๏ธ|โœ“) โ–บmain "#]] ); @@ -2453,7 +2415,7 @@ fn orphan_reparent_same_target_tip_keeps_single_parent() -> Result<()> { push_remote_name: None, }); add_stack(&mut meta, 1, "A", StackState::InWorkspace); - let graph = but_graph::Graph::from_head( + let mut workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -2462,7 +2424,6 @@ fn orphan_reparent_same_target_tip_keeps_single_parent() -> Result<()> { ..Options::limited() }, )?; - let mut workspace = graph.into_workspace()?; integrate_and_materialize( &mut workspace, @@ -2503,7 +2464,7 @@ fn orphan_reparent_two_stacks_through_merge_target() -> Result<()> { }); add_stack(&mut meta, 1, "A", StackState::InWorkspace); add_stack(&mut meta, 2, "B", StackState::InWorkspace); - let graph = but_graph::Graph::from_head( + let mut workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -2512,7 +2473,6 @@ fn orphan_reparent_two_stacks_through_merge_target() -> Result<()> { ..Options::limited() }, )?; - let mut workspace = graph.into_workspace()?; integrate_and_materialize( &mut workspace, @@ -2552,11 +2512,11 @@ fn review_hint_fully_integrates_direct_checkout_branch() -> Result<()> { sha: target_sha, push_remote_name: None, }); - let graph = but_graph::Graph::from_commit_traversal_tips( + let workspace = but_graph::Workspace::from_seeds( &repo, [ - Tip::entrypoint(repo.head_id()?.detach(), Some("refs/heads/A".try_into()?)), - Tip::integrated( + Seed::entrypoint(repo.head_id()?.detach(), Some("refs/heads/A".try_into()?)), + Seed::integrated( repo.rev_parse_single("origin/main")?.detach(), Some("refs/remotes/origin/main".try_into()?), ), @@ -2568,11 +2528,10 @@ fn review_hint_fully_integrates_direct_checkout_branch() -> Result<()> { ..Options::limited() }, )?; - let mut workspace = graph.into_workspace()?; - let project_meta = workspace.graph.project_meta.clone(); + let project_meta = workspace.project_meta().clone(); let out = integrate_upstream_with_hints( - &mut workspace, + &workspace, &mut meta, project_meta, &repo, @@ -2615,7 +2574,7 @@ fn review_hint_integrates_squashed_two_commit_stack_in_managed_workspace() -> Re }); add_stack(&mut meta, 1, "A", StackState::InWorkspace); add_stack(&mut meta, 2, "B", StackState::InWorkspace); - let graph = but_graph::Graph::from_head( + let workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -2628,11 +2587,11 @@ fn review_hint_integrates_squashed_two_commit_stack_in_managed_workspace() -> Re snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* b96a78e (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* f9bc1ee (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -| * ad1d22b (A) add A2 -| * fe98e29 add A1 -* | b38b04b (B) add B1 +| * b38b04b (B) add B1 +* | ad1d22b (A) add A2 +* | fe98e29 add A1 |/ | * 56057f2 (origin/main) squash A |/ @@ -2642,7 +2601,6 @@ fn review_hint_integrates_squashed_two_commit_stack_in_managed_workspace() -> Re .raw() ); - let mut workspace = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -2658,10 +2616,10 @@ fn review_hint_integrates_squashed_two_commit_stack_in_managed_workspace() -> Re "#]] ); - let project_meta = workspace.graph.project_meta.clone(); + let project_meta = workspace.project_meta().clone(); let out = integrate_upstream_with_hints( - &mut workspace, + &workspace, &mut meta, project_meta.clone(), &repo, @@ -2675,8 +2633,8 @@ fn review_hint_integrates_squashed_two_commit_stack_in_managed_workspace() -> Re )?; out.rebase.materialize()?; - let graph = but_graph::Graph::from_head(&repo, &meta, project_meta, Options::limited())?; - let workspace = graph.into_workspace()?; + let workspace = + but_graph::Workspace::from_head(&repo, &meta, project_meta, Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -2690,7 +2648,7 @@ fn review_hint_integrates_squashed_two_commit_stack_in_managed_workspace() -> Re snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* e4abb28 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 4cb8cfe (HEAD -> gitbutler/workspace) GitButler Workspace Commit * b38b04b (B) add B1 | * 56057f2 (origin/main) squash A |/ @@ -2729,11 +2687,11 @@ fn review_hint_integrates_squashed_two_commit_direct_checkout_branch() -> Result sha: target_sha, push_remote_name: None, }); - let graph = but_graph::Graph::from_commit_traversal_tips( + let workspace = but_graph::Workspace::from_seeds( &repo, [ - Tip::entrypoint(repo.head_id()?.detach(), Some("refs/heads/A".try_into()?)), - Tip::integrated( + Seed::entrypoint(repo.head_id()?.detach(), Some("refs/heads/A".try_into()?)), + Seed::integrated( repo.rev_parse_single("origin/main")?.detach(), Some("refs/remotes/origin/main".try_into()?), ), @@ -2757,7 +2715,6 @@ fn review_hint_integrates_squashed_two_commit_direct_checkout_branch() -> Result "#]] ); - let mut workspace = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -2770,10 +2727,10 @@ fn review_hint_integrates_squashed_two_commit_direct_checkout_branch() -> Result "#]] ); - let project_meta = workspace.graph.project_meta.clone(); + let project_meta = workspace.project_meta().clone(); let out = integrate_upstream_with_hints( - &mut workspace, + &workspace, &mut meta, project_meta.clone(), &repo, @@ -2786,8 +2743,8 @@ fn review_hint_integrates_squashed_two_commit_direct_checkout_branch() -> Result }], )?; out.rebase.materialize()?; - let graph = but_graph::Graph::from_head(&repo, &meta, project_meta, Options::limited())?; - let workspace = graph.into_workspace()?; + let workspace = + but_graph::Workspace::from_head(&repo, &meta, project_meta, Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -2836,7 +2793,7 @@ fn review_hint_integrates_squashed_prefix_and_keeps_extra_commit_in_managed_work push_remote_name: None, }); add_stack(&mut meta, 1, "A", StackState::InWorkspace); - let graph = but_graph::Graph::from_head( + let mut workspace = but_graph::Workspace::from_head( &repo, &meta, project_meta(&meta)?, @@ -2860,7 +2817,6 @@ fn review_hint_integrates_squashed_prefix_and_keeps_extra_commit_in_managed_work "#]] ); - let mut workspace = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -2887,9 +2843,8 @@ fn review_hint_integrates_squashed_prefix_and_keeps_extra_commit_in_managed_work }], )?; - let graph = - but_graph::Graph::from_head(&repo, &meta, project_meta(&meta)?, Options::limited())?; - let workspace = graph.into_workspace()?; + let workspace = + but_graph::Workspace::from_head(&repo, &meta, project_meta(&meta)?, Options::limited())?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -2929,11 +2884,11 @@ fn review_hint_integrates_squashed_prefix_and_keeps_extra_commit_in_direct_check sha: target_sha, push_remote_name: None, }); - let graph = but_graph::Graph::from_commit_traversal_tips( + let workspace = but_graph::Workspace::from_seeds( &repo, [ - Tip::entrypoint(repo.head_id()?.detach(), Some("refs/heads/A".try_into()?)), - Tip::integrated( + Seed::entrypoint(repo.head_id()?.detach(), Some("refs/heads/A".try_into()?)), + Seed::integrated( repo.rev_parse_single("origin/main")?.detach(), Some("refs/remotes/origin/main".try_into()?), ), @@ -2959,7 +2914,6 @@ fn review_hint_integrates_squashed_prefix_and_keeps_extra_commit_in_direct_check "#]] ); - let mut workspace = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -2973,13 +2927,13 @@ fn review_hint_integrates_squashed_prefix_and_keeps_extra_commit_in_direct_check "#]] ); - let current_project_meta = workspace.graph.project_meta.clone(); + let current_project_meta = workspace.project_meta().clone(); let but_workspace::IntegrateUpstreamOutcome { rebase, project_meta: updated_project_meta, .. } = integrate_upstream_with_hints( - &mut workspace, + &workspace, &mut meta, current_project_meta, &repo, @@ -2993,11 +2947,11 @@ fn review_hint_integrates_squashed_prefix_and_keeps_extra_commit_in_direct_check )?; rebase.materialize()?; - let graph = but_graph::Graph::from_commit_traversal_tips( + let workspace = but_graph::Workspace::from_seeds( &repo, [ - Tip::entrypoint(repo.head_id()?.detach(), repo.head_name()?), - Tip::integrated( + Seed::entrypoint(repo.head_id()?.detach(), repo.head_name()?), + Seed::integrated( repo.rev_parse_single("origin/main")?.detach(), Some("refs/remotes/origin/main".try_into()?), ), @@ -3006,7 +2960,6 @@ fn review_hint_integrates_squashed_prefix_and_keeps_extra_commit_in_direct_check updated_project_meta, Options::limited(), )?; - let workspace = graph.into_workspace()?; snapbox::assert_data_eq!( graph_workspace(&workspace).to_string(), snapbox::str![[r#" @@ -3054,11 +3007,11 @@ fn review_hint_integrates_prefix_but_keeps_extra_local_commit() -> Result<()> { .args(["commit", "-m", "post-review local commit"]) .run(); - let graph = but_graph::Graph::from_commit_traversal_tips( + let workspace = but_graph::Workspace::from_seeds( &repo, [ - Tip::entrypoint(repo.head_id()?.detach(), Some("refs/heads/A".try_into()?)), - Tip::integrated( + Seed::entrypoint(repo.head_id()?.detach(), Some("refs/heads/A".try_into()?)), + Seed::integrated( repo.rev_parse_single("origin/main")?.detach(), Some("refs/remotes/origin/main".try_into()?), ), @@ -3070,11 +3023,10 @@ fn review_hint_integrates_prefix_but_keeps_extra_local_commit() -> Result<()> { ..Options::limited() }, )?; - let mut workspace = graph.into_workspace()?; - let project_meta = workspace.graph.project_meta.clone(); + let project_meta = workspace.project_meta().clone(); let out = integrate_upstream_with_hints( - &mut workspace, + &workspace, &mut meta, project_meta, &repo, @@ -3120,7 +3072,8 @@ fn integrate_and_materialize( project_meta, } = integrate_upstream(workspace, meta, project_meta(&*meta)?, repo, updates)?; let materialized = rebase.materialize()?; - if let Some(ref_name) = materialized.workspace.ref_name() + workspace.refresh_from_commit_graph(materialized.arena().clone(), repo, materialized.meta)?; + if let Some(ref_name) = workspace.ref_name() && let Some(ws_meta) = ws_meta { let mut md = materialized.meta.workspace(ref_name)?; @@ -3153,7 +3106,8 @@ fn integrate_with_hints_and_materialize( review_hints, )?; let materialized = rebase.materialize()?; - if let Some(ref_name) = materialized.workspace.ref_name() + workspace.refresh_from_commit_graph(materialized.arena().clone(), repo, materialized.meta)?; + if let Some(ref_name) = workspace.ref_name() && let Some(ws_meta) = ws_meta { let mut md = materialized.meta.workspace(ref_name)?; diff --git a/crates/but-workspace/tests/workspace/worktree.rs b/crates/but-workspace/tests/workspace/worktree.rs index 7687fee06e0..10a2c0d71c9 100644 --- a/crates/but-workspace/tests/workspace/worktree.rs +++ b/crates/but-workspace/tests/workspace/worktree.rs @@ -1,7 +1,7 @@ use anyhow::{Context, Result}; -use but_graph::init::Options; +use but_graph::walk::Options; use but_meta::virtual_branches_legacy_types::Target; -use but_rebase::graph_rebase::mutate::RelativeTo; +use but_rebase::graph_rebase::selector::RelativeTo; use but_workspace::{ BottomUpdate, BottomUpdateKind, integrate_upstream, worktree_conflicts_for_rebase, }; @@ -23,11 +23,11 @@ fn conflict_preview_reports_dirty_worktree_paths() -> Result<()> { repo.workdir_path("shared.txt").expect("non-bare"), "dirty\n", )?; - let mut workspace = workspace_for_stack(&repo, &meta)?; + let workspace = workspace_for_stack(&repo, &meta)?; let project_meta = project_meta(&meta)?; let rebase = integrate_upstream( - &mut workspace, + &workspace, &mut meta, project_meta, &repo, @@ -38,7 +38,7 @@ fn conflict_preview_reports_dirty_worktree_paths() -> Result<()> { )? .rebase; - let conflicts = worktree_conflicts_for_rebase(&rebase)?; + let conflicts = worktree_conflicts_for_rebase(&workspace, &rebase)?; assert_eq!( conflicts, @@ -60,11 +60,11 @@ fn conflict_preview_includes_index_conflicts_when_worktree_is_dirty() -> Result< repo.workdir_path("unrelated.txt").expect("non-bare"), "dirty\n", )?; - let mut workspace = workspace_for_stack(&repo, &meta)?; + let workspace = workspace_for_stack(&repo, &meta)?; let project_meta = project_meta(&meta)?; let rebase = integrate_upstream( - &mut workspace, + &workspace, &mut meta, project_meta, &repo, @@ -75,7 +75,7 @@ fn conflict_preview_includes_index_conflicts_when_worktree_is_dirty() -> Result< )? .rebase; - let conflicts = worktree_conflicts_for_rebase(&rebase)?; + let conflicts = worktree_conflicts_for_rebase(&workspace, &rebase)?; assert_eq!( conflicts, @@ -92,11 +92,11 @@ fn conflict_preview_uses_rebase_repo_for_preview_objects() -> Result<()> { repo.workdir_path("shared.txt").expect("non-bare"), "dirty\n", )?; - let mut workspace = workspace_for_stack(&repo, &meta)?; + let workspace = workspace_for_stack(&repo, &meta)?; let project_meta = project_meta(&meta)?; let rebase = integrate_upstream( - &mut workspace, + &workspace, &mut meta, project_meta, &repo, @@ -107,19 +107,16 @@ fn conflict_preview_uses_rebase_repo_for_preview_objects() -> Result<()> { )? .rebase; - let preview_workspace = rebase.overlayed_graph()?.into_workspace()?; + let preview_workspace = but_workspace::workspace::overlayed_workspace(&workspace, &rebase)?; let preview_head = preview_workspace - .graph - .entrypoint()? - .commit() - .context("preview workspace should have a head commit")? - .id; + .entrypoint_commit_id()? + .context("preview workspace should have a head commit")?; assert!( repo.find_object(preview_head).is_err(), "preview commits should not have to exist in the persistent repository before materialization" ); - let conflicts = worktree_conflicts_for_rebase(&rebase)?; + let conflicts = worktree_conflicts_for_rebase(&workspace, &rebase)?; assert_eq!( conflicts, @@ -136,11 +133,11 @@ fn conflict_preview_returns_empty_for_non_conflicting_dirty_worktree() -> Result repo.workdir_path("unrelated.txt").expect("non-bare"), "dirty\n", )?; - let mut workspace = workspace_for_stack(&repo, &meta)?; + let workspace = workspace_for_stack(&repo, &meta)?; let project_meta = project_meta(&meta)?; let rebase = integrate_upstream( - &mut workspace, + &workspace, &mut meta, project_meta, &repo, @@ -151,7 +148,7 @@ fn conflict_preview_returns_empty_for_non_conflicting_dirty_worktree() -> Result )? .rebase; - let conflicts = worktree_conflicts_for_rebase(&rebase)?; + let conflicts = worktree_conflicts_for_rebase(&workspace, &rebase)?; assert!( conflicts.is_empty(), @@ -168,11 +165,11 @@ fn conflict_preview_returns_empty_for_ignored_only_worktree_changes() -> Result< repo.workdir_path("ignored.txt").expect("non-bare"), "ignored\n", )?; - let mut workspace = workspace_for_stack(&repo, &meta)?; + let workspace = workspace_for_stack(&repo, &meta)?; let project_meta = project_meta(&meta)?; let rebase = integrate_upstream( - &mut workspace, + &workspace, &mut meta, project_meta, &repo, @@ -183,7 +180,7 @@ fn conflict_preview_returns_empty_for_ignored_only_worktree_changes() -> Result< )? .rebase; - let conflicts = worktree_conflicts_for_rebase(&rebase)?; + let conflicts = worktree_conflicts_for_rebase(&workspace, &rebase)?; assert!( conflicts.is_empty(), @@ -218,7 +215,7 @@ fn workspace_for_stack( meta: &but_meta::VirtualBranchesTomlMetadata, ) -> Result { let target_sha = repo.rev_parse_single("main")?.detach(); - let ws = but_graph::Graph::from_head( + let ws = but_graph::Workspace::from_head( repo, meta, project_meta(meta)?, @@ -226,8 +223,7 @@ fn workspace_for_stack( extra_target_commit_id: Some(target_sha), ..Options::limited() }, - )? - .into_workspace()?; + )?; Ok(ws) } diff --git a/crates/but-worktrees/src/integrate.rs b/crates/but-worktrees/src/integrate.rs index a9ec9fca075..9a3d6ff9b74 100644 --- a/crates/but-worktrees/src/integrate.rs +++ b/crates/but-worktrees/src/integrate.rs @@ -97,15 +97,15 @@ pub fn worktree_integrate( /// Performs the workspace integration in-memory using the graph editor, /// returning the status, and the un-materialized rebase if it's integratable. -fn worktree_integration_inner<'ws, 'meta, M: RefMetadata>( +fn worktree_integration_inner<'meta, M: RefMetadata>( repo: &gix::Repository, - ws: &'ws mut but_graph::Workspace, + ws: &mut but_graph::Workspace, meta: &'meta mut M, id: &WorktreeId, target: &gix::refs::FullNameRef, ) -> Result<( WorktreeIntegrationStatus, - Option>, + Option>, )> { if !ws.refname_is_segment(target) { bail!("Branch {} not found in workspace", target.shorten()); @@ -151,7 +151,7 @@ fn worktree_integration_inner<'ws, 'meta, M: RefMetadata>( } // State needed later which can't be read while the editor borrows `ws`. - let ws_commit_id = ws.graph.managed_entrypoint_commit(repo)?.map(|c| c.id); + let ws_commit_id = ws.managed_entrypoint_commit_id(repo)?; let head_id = repo.head_id().ok().map(|id| id.detach()); let head_tree_id = repo.head_tree_id_or_empty()?.detach(); #[expect( @@ -168,7 +168,7 @@ fn worktree_integration_inner<'ws, 'meta, M: RefMetadata>( .context("Failed to find author signature")? .to_owned()?; - let mut editor = Editor::create(ws, meta, repo)?; + let mut editor = Editor::create(ws.commit_graph(), ws.project_meta(), meta, repo)?; // Create the squash commit in the editor's in-memory repository; it is // only persisted if the result gets materialized. diff --git a/crates/but/src/command/commit/move.rs b/crates/but/src/command/commit/move.rs index b9e3942acb3..e378807422a 100644 --- a/crates/but/src/command/commit/move.rs +++ b/crates/but/src/command/commit/move.rs @@ -6,7 +6,8 @@ use crate::{ use anyhow::bail; use but_core::{DryRun, sync::RepoExclusive}; use but_ctx::Context; -use but_rebase::graph_rebase::mutate::{InsertSide, RelativeTo}; +use but_rebase::graph_rebase::mutate::InsertSide; +use but_rebase::graph_rebase::selector::RelativeTo; pub(crate) fn handle_resolved_with_perm( ctx: &mut Context, diff --git a/crates/but/src/command/legacy/commit.rs b/crates/but/src/command/legacy/commit.rs index 7fe688859c9..2840894fe46 100644 --- a/crates/but/src/command/legacy/commit.rs +++ b/crates/but/src/command/legacy/commit.rs @@ -9,7 +9,8 @@ use but_core::{ sync::{RepoExclusive, RepoShared}, ui::TreeChange, }; -use but_rebase::graph_rebase::mutate::{InsertSide, RelativeTo}; +use but_rebase::graph_rebase::mutate::InsertSide; +use but_rebase::graph_rebase::selector::RelativeTo; use gitbutler_oplog::entry::{OperationKind, SnapshotDetails}; use gitbutler_repo::hooks; diff --git a/crates/but/src/command/legacy/commit2.rs b/crates/but/src/command/legacy/commit2.rs index f0d7763447c..aee5a7af516 100644 --- a/crates/but/src/command/legacy/commit2.rs +++ b/crates/but/src/command/legacy/commit2.rs @@ -6,7 +6,8 @@ use but_core::{ sync::{RepoExclusive, RepoExclusiveGuard}, }; use but_ctx::Context; -use but_rebase::graph_rebase::mutate::{InsertSide, RelativeTo}; +use but_rebase::graph_rebase::mutate::InsertSide; +use but_rebase::graph_rebase::selector::RelativeTo; use but_transaction::{IntermediateCommitCreateResult, Transaction}; use but_workspace::{RefInfo, branch::create_reference::Anchor}; use gitbutler_oplog::entry::{OperationKind, SnapshotDetails}; diff --git a/crates/but/src/command/legacy/move2.rs b/crates/but/src/command/legacy/move2.rs index a009a2c1cf0..71e761e9308 100644 --- a/crates/but/src/command/legacy/move2.rs +++ b/crates/but/src/command/legacy/move2.rs @@ -3,7 +3,7 @@ use std::fmt::Display; use bstr::{BString, ByteSlice}; use but_core::{DiffSpec, DryRun, RefMetadata, ref_metadata::StackId, sync::RepoExclusive}; use but_ctx::Context; -use but_rebase::graph_rebase::mutate::RelativeTo; +use but_rebase::graph_rebase::selector::RelativeTo; use but_transaction::Transaction; use but_workspace::branch::create_reference::Anchor; use gitbutler_oplog::entry::{OperationKind, SnapshotDetails}; diff --git a/crates/but/src/command/legacy/rub/amend.rs b/crates/but/src/command/legacy/rub/amend.rs index 654a95e96d5..80a1931ff7c 100644 --- a/crates/but/src/command/legacy/rub/amend.rs +++ b/crates/but/src/command/legacy/rub/amend.rs @@ -53,8 +53,8 @@ fn amend_diff_specs( perm: &mut RepoExclusive, ) -> anyhow::Result<(Option, Vec)> { let mut meta = ctx.meta()?; - let (repo, mut ws, _) = ctx.workspace_mut_and_db_with_perm(perm)?; - let editor = Editor::create(&mut ws, &mut meta, &repo)?; + let (repo, ws, _) = ctx.workspace_mut_and_db_with_perm(perm)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; let outcome = but_workspace::commit::commit_amend( editor, oid, diff --git a/crates/but/src/command/legacy/rub/mod.rs b/crates/but/src/command/legacy/rub/mod.rs index 6cf5e9b4142..7b3092b32d4 100644 --- a/crates/but/src/command/legacy/rub/mod.rs +++ b/crates/but/src/command/legacy/rub/mod.rs @@ -11,7 +11,8 @@ use but_api::commit::types::{ use but_core::{DiffSpec, DryRun, ref_metadata::StackId, sync::RepoExclusive}; use but_ctx::Context; use but_hunk_assignment::{HunkAssignment, HunkAssignmentRequest, HunkAssignmentTarget}; -use but_rebase::graph_rebase::mutate::{InsertSide, RelativeTo}; +use but_rebase::graph_rebase::mutate::InsertSide; +use but_rebase::graph_rebase::selector::RelativeTo; mod amend; mod assign; pub(crate) mod squash; diff --git a/crates/but/src/command/legacy/setup.rs b/crates/but/src/command/legacy/setup.rs index 56ce3edd383..b6b945bcdfc 100644 --- a/crates/but/src/command/legacy/setup.rs +++ b/crates/but/src/command/legacy/setup.rs @@ -498,7 +498,7 @@ pub fn check_project_setup(ctx: &Context, perm: &RepoShared) -> anyhow::Result, + push_statuses_by_segment_id: &HashMap< + super::SegmentStatusKey, + but_workspace::ui::PushStatus, + >, local_commits_by_id: &HashMap, remote_commits_by_id: &HashMap, review_id: Option, @@ -348,7 +350,7 @@ impl Branch { .collect::>>()?; let push_status = push_statuses_by_segment_id - .get(&segment.inner.id) + .get(&super::segment_status_key_with_id(&segment)) .copied() .unwrap_or_else(|| { eprintln!("warning: head_info does not have segment that graph has"); diff --git a/crates/but/src/command/legacy/status/mod.rs b/crates/but/src/command/legacy/status/mod.rs index ddacef3b2e2..ef74fddcc5b 100644 --- a/crates/but/src/command/legacy/status/mod.rs +++ b/crates/but/src/command/legacy/status/mod.rs @@ -12,7 +12,6 @@ use but_core::{ }; use but_ctx::Context; use but_forge::ForgeReview; -use but_graph::SegmentIndex; use but_workspace::{ ref_info::{Commit, LocalCommit, LocalCommitRelation, Segment}, ui::PushStatus, @@ -183,6 +182,28 @@ struct UpstreamState { author_email: String, } +/// Identifies a stack segment across two projections of the same workspace: +/// its branch name when named, and the commit it rests on otherwise. +pub(super) type SegmentStatusKey = (Option, Option); + +/// The key for the id-mapped segment wrapper, whose `inner` has its commit +/// lists blanked โ€” the originals ride in `workspace_commits`. +pub(super) fn segment_status_key_with_id(segment: &crate::id::SegmentWithId) -> SegmentStatusKey { + ( + segment.inner.ref_name().map(ToOwned::to_owned), + segment.workspace_commits.first().map(|c| c.inner.id), + ) +} + +pub(super) fn segment_status_key_ui( + segment: &but_workspace::ref_info::Segment, +) -> SegmentStatusKey { + ( + segment.ref_info.as_ref().map(|ri| ri.ref_name.clone()), + segment.commits.first().map(|c| c.id), + ) +} + struct StatusContext<'a> { flags: StatusFlags, stack_details: Vec, @@ -202,7 +223,7 @@ struct StatusContext<'a> { is_paged: bool, should_truncate_for_terminal: bool, id_map: IdMap, - push_statuses_by_segment_id: HashMap, + push_statuses_by_segment_id: HashMap, local_commits_by_id: HashMap, remote_commits_by_id: HashMap, base_branch: Option, @@ -358,18 +379,19 @@ fn build_status_context<'a>( &ws, &repo, but_workspace::ref_info::Options { - project_meta: ws.graph.project_meta.clone(), + project_meta: ws.project_meta().clone(), expensive_commit_info: true, ..Default::default() }, )?; - let mut push_statuses_by_segment_id = HashMap::::new(); + let mut push_statuses_by_segment_id = HashMap::::new(); let mut local_commits_by_id = HashMap::::new(); let mut remote_commits_by_id = HashMap::::new(); let mut commit_id_to_change_id = gix::hashtable::HashMap::::default(); for stack in head_info.stacks { for segment in stack.segments { + let key = segment_status_key_ui(&segment); let Segment { commits, commits_on_remote, @@ -384,7 +406,7 @@ fn build_status_context<'a>( for remote_commit in commits_on_remote { remote_commits_by_id.insert(remote_commit.id, remote_commit); } - push_statuses_by_segment_id.insert(segment.id, push_status); + push_statuses_by_segment_id.insert(key, push_status); } } @@ -751,7 +773,7 @@ fn branch_is_merged_upstream( if matches!( status_ctx .push_statuses_by_segment_id - .get(&segment.inner.id), + .get(&segment_status_key_with_id(segment)), Some(PushStatus::Integrated) ) || matches!( branch_merge_status(status_ctx, segment), @@ -1035,14 +1057,15 @@ fn ci_map( ctx: &Context, cache_config: &but_forge::CacheConfig, stack_details: &[StackEntry], - push_statuses_by_segment_id: &HashMap, + push_statuses_by_segment_id: &HashMap, review_map: &HashMap>, ) -> Result>, anyhow::Error> { let mut ci_map = BTreeMap::new(); for (_, (stack_with_id, _)) in stack_details { if let Some(stack_with_id) = stack_with_id { for segment in &stack_with_id.segments { - let push_status = push_statuses_by_segment_id.get(&segment.inner.id); + let push_status = + push_statuses_by_segment_id.get(&segment_status_key_with_id(segment)); if push_status.is_none() { eprintln!("warning: head_info does not contain segment that graph has"); } diff --git a/crates/but/src/command/legacy/status/tui/operations.rs b/crates/but/src/command/legacy/status/tui/operations.rs index 6bba2681ed3..40b5af3195b 100644 --- a/crates/but/src/command/legacy/status/tui/operations.rs +++ b/crates/but/src/command/legacy/status/tui/operations.rs @@ -13,7 +13,8 @@ use but_api::{ }; use but_core::{DryRun, diff::CommitDetails, ref_metadata::StackId}; use but_ctx::Context; -use but_rebase::graph_rebase::mutate::{InsertSide, RelativeTo}; +use but_rebase::graph_rebase::mutate::InsertSide; +use but_rebase::graph_rebase::selector::RelativeTo; use gitbutler_operating_modes::OperatingMode; use gitbutler_oplog::entry::Snapshot; use gix::prelude::ObjectIdExt; diff --git a/crates/but/src/command/legacy/status/tui/tests/move_tests.rs b/crates/but/src/command/legacy/status/tui/tests/move_tests.rs index 967926fa6e0..4d54a2e87a5 100644 --- a/crates/but/src/command/legacy/status/tui/tests/move_tests.rs +++ b/crates/but/src/command/legacy/status/tui/tests/move_tests.rs @@ -227,7 +227,7 @@ fn move_branch_to_merge_base_tears_off_branch() { #[test] fn moving_multiple_commits() { - let env = Sandbox::init_scenario_with_target_and_default_settings("two-stacks"); + let env = Sandbox::init_scenario_with_target_and_default_settings("two-stacks-second-double"); env.setup_metadata(&["A", "B"]); let mut tui = test_tui(env); diff --git a/crates/but/src/command/legacy/status/tui/tests/snapshots/moving_multiple_commits_001.svg b/crates/but/src/command/legacy/status/tui/tests/snapshots/moving_multiple_commits_001.svg index 63ba2e283aa..543c4c9d96e 100644 --- a/crates/but/src/command/legacy/status/tui/tests/snapshots/moving_multiple_commits_001.svg +++ b/crates/but/src/command/legacy/status/tui/tests/snapshots/moving_multiple_commits_001.svg @@ -26,8 +26,8 @@ A ] โ”Šโ— - t - pm + v + yq add A โ”œโ•ฏ @@ -38,19 +38,26 @@ B ] โ”Šโ— - l - rm + zq + r add B โ”œโ•ฏ โ”Š - โ”ด - 0dc3733 - (common - base) - 2000-01-02 - add - M + โ”Š + โ— + 7608322 + (upstream) + โซ + 1 + commit + โ”œโ•ฏ + 7608322 + (common + base) + 2000-01-02 + add + M normal โ†‘/k up diff --git a/crates/but/src/command/legacy/status/tui/tests/snapshots/moving_multiple_commits_002.svg b/crates/but/src/command/legacy/status/tui/tests/snapshots/moving_multiple_commits_002.svg index 4aa4953b1ae..81d083ebe61 100644 --- a/crates/but/src/command/legacy/status/tui/tests/snapshots/moving_multiple_commits_002.svg +++ b/crates/but/src/command/legacy/status/tui/tests/snapshots/moving_multiple_commits_002.svg @@ -29,8 +29,8 @@ ] โ”Š โœ”๏ธŽ - t - pm + v + yq add A โ”œโ•ฏ @@ -42,19 +42,26 @@ ] โ”Š โœ”๏ธŽ - l - rm + zq + r add B โ”œโ•ฏ โ”Š - โ”ด - 0dc3733 - (common - base) - 2000-01-02 - add - M + โ”Š + โ— + 7608322 + (upstream) + โซ + 1 + commit + โ”œโ•ฏ + 7608322 + (common + base) + 2000-01-02 + add + M normal โ†‘/k up diff --git a/crates/but/src/command/legacy/status/tui/tests/snapshots/moving_multiple_commits_003.svg b/crates/but/src/command/legacy/status/tui/tests/snapshots/moving_multiple_commits_003.svg index 111e44007f9..403eee15b90 100644 --- a/crates/but/src/command/legacy/status/tui/tests/snapshots/moving_multiple_commits_003.svg +++ b/crates/but/src/command/legacy/status/tui/tests/snapshots/moving_multiple_commits_003.svg @@ -37,8 +37,8 @@ << source >> - t - pm + v + yq add A โ”œโ•ฏ @@ -60,19 +60,26 @@ << source >> - l - rm + zq + r add B โ”œโ•ฏ โ”Š - โ”ด - 0dc3733 - (common - base) - 2000-01-02 - add - M + โ”Š + โ— + 7608322 + (upstream) + โซ + 1 + commit + โ”œโ•ฏ + 7608322 + (common + base) + 2000-01-02 + add + M move enter confirm diff --git a/crates/but/src/command/legacy/status/tui/tests/snapshots/moving_multiple_commits_004.svg b/crates/but/src/command/legacy/status/tui/tests/snapshots/moving_multiple_commits_004.svg index f6697e76871..774dfaba26c 100644 --- a/crates/but/src/command/legacy/status/tui/tests/snapshots/moving_multiple_commits_004.svg +++ b/crates/but/src/command/legacy/status/tui/tests/snapshots/moving_multiple_commits_004.svg @@ -38,8 +38,8 @@ << source >> - t - pm + v + yq add A โ”œโ•ฏ @@ -57,19 +57,26 @@ << noop >> - l - rm + zq + r add B โ”œโ•ฏ โ”Š - โ”ด - 0dc3733 - (common - base) - 2000-01-02 - add - M + โ”Š + โ— + 7608322 + (upstream) + โซ + 1 + commit + โ”œโ•ฏ + 7608322 + (common + base) + 2000-01-02 + add + M move enter confirm diff --git a/crates/but/src/command/legacy/status/tui/tests/snapshots/moving_multiple_commits_005.svg b/crates/but/src/command/legacy/status/tui/tests/snapshots/moving_multiple_commits_005.svg index d4e49f25b96..45ec6699324 100644 --- a/crates/but/src/command/legacy/status/tui/tests/snapshots/moving_multiple_commits_005.svg +++ b/crates/but/src/command/legacy/status/tui/tests/snapshots/moving_multiple_commits_005.svg @@ -26,8 +26,8 @@ A ] โ”Šโ— - t - pm + v + yq add A โ”œโ•ฏ @@ -38,19 +38,26 @@ B ] โ”Šโ— - l - rm + zq + r add B โ”œโ•ฏ โ”Š - โ”ด - 0dc3733 - (common - base) - 2000-01-02 - add - M + โ”Š + โ— + 7608322 + (upstream) + โซ + 1 + commit + โ”œโ•ฏ + 7608322 + (common + base) + 2000-01-02 + add + M normal โ†‘/k up diff --git a/crates/but/src/command/legacy/status/tui/tests/utils.rs b/crates/but/src/command/legacy/status/tui/tests/utils.rs index a9e99731494..36d0d671eea 100644 --- a/crates/but/src/command/legacy/status/tui/tests/utils.rs +++ b/crates/but/src/command/legacy/status/tui/tests/utils.rs @@ -212,6 +212,13 @@ impl TestTui { { let mut other_messages = Vec::new(); + // Force a full redraw: ratatui's diff assumes real-terminal side effects (overwriting + // half of a wide char blanks the other half) that TestBackend does not emulate, so + // incremental updates can leave stale cells in snapshots (ratatui-core 0.1.2). + self.terminal + .clear() + .expect("failed to clear test terminal"); + with_stable_commit_env(|| { let mut ctx = self.env().context(); let mut out = TestTuiInputOutputChannel(&mut self.out); diff --git a/crates/but/src/command/legacy/workspace_target.rs b/crates/but/src/command/legacy/workspace_target.rs index fee5dda7964..864df1ccddd 100644 --- a/crates/but/src/command/legacy/workspace_target.rs +++ b/crates/but/src/command/legacy/workspace_target.rs @@ -112,14 +112,13 @@ fn target_ref_name_from_workspace(workspace: &but_graph::Workspace) -> Option Option { workspace - .graph - .project_meta + .project_meta() .push_remote .clone() .or_else(|| workspace.remote_name()) diff --git a/crates/but/src/id/tests.rs b/crates/but/src/id/tests.rs index e9a3577cf5a..eb965c80671 100644 --- a/crates/but/src/id/tests.rs +++ b/crates/but/src/id/tests.rs @@ -2666,21 +2666,12 @@ mod util { for id in remote_commit_ids { commits_on_remote.push(commit(id, None)) } - StackSegment { - ref_info, - remote_tracking_ref_name: None, - sibling_segment_id: None, - remote_tracking_branch_segment_id: None, - id: Default::default(), - commits, - commits_outside: None, - base, - base_segment_id: None, - commits_by_segment: Vec::new(), - commits_on_remote, - metadata: None, - is_entrypoint: false, - } + let mut segment = StackSegment::default_for_testing(); + segment.ref_info = ref_info; + segment.commits = commits; + segment.base = base; + segment.commits_on_remote = commits_on_remote; + segment } pub fn stack(segments: [StackSegment; N]) -> Stack { diff --git a/crates/but/src/legacy/workspace.rs b/crates/but/src/legacy/workspace.rs index 9cd41e18da3..b9cbc4d13e0 100644 --- a/crates/but/src/legacy/workspace.rs +++ b/crates/but/src/legacy/workspace.rs @@ -65,7 +65,7 @@ fn head_info( }; let options = ref_info::Options { project_meta: ctx.project_meta()?, - traversal: but_graph::init::Options::limited(), + traversal: but_graph::walk::Options::limited(), expensive_commit_info, gerrit_mode, }; diff --git a/crates/but/src/utils/metrics.rs b/crates/but/src/utils/metrics.rs index 20acaaa612d..822c51fded8 100644 --- a/crates/but/src/utils/metrics.rs +++ b/crates/but/src/utils/metrics.rs @@ -578,14 +578,13 @@ fn read_only_workspace(ctx: &but_ctx::Context) -> Option { ctx.project_data_dir(), ) .ok()?; - let graph = but_graph::Graph::from_head( + but_graph::Workspace::from_head( &repo, &meta, ctx.project_meta().ok()?, - but_graph::init::Options::limited(), + but_graph::walk::Options::limited(), ) - .ok()?; - graph.into_workspace().ok() + .ok() } #[derive(Debug, Clone)] diff --git a/crates/but/tests/but/command/branch/new.rs b/crates/but/tests/but/command/branch/new.rs index 603f617b265..8a821a67d4d 100644 --- a/crates/but/tests/but/command/branch/new.rs +++ b/crates/but/tests/but/command/branch/new.rs @@ -213,3 +213,27 @@ Hint: run `but help` for all commands "#]]); } + +#[test] +fn creates_new_branches_on_top_beside_existing_stack() { + let env = Sandbox::init_scenario_with_target_and_default_settings("one-stack"); + env.setup_metadata(&["A"]); + + env.but("branch new one").assert().success(); + + env.but("status").assert().success().stdout_eq(str![[r#" +โ•ญโ”„zz [uncommitted] (no changes) +โ”Š +โ”Šโ•ญโ”„on [one] (no commits) +โ”œโ•ฏ +โ”Š +โ”Šโ•ญโ”„g0 [A] +โ”Šโ— tpm add A +โ”œโ•ฏ +โ”Š +โ”ด 0dc3733 (common base) 2000-01-02 add M + +Hint: run `but help` for all commands + +"#]]); +} diff --git a/crates/but/tests/but/command/branch/update.rs b/crates/but/tests/but/command/branch/update.rs index a3fdd4ed24e..634d59870cd 100644 --- a/crates/but/tests/but/command/branch/update.rs +++ b/crates/but/tests/but/command/branch/update.rs @@ -1,4 +1,4 @@ -use snapbox::IntoData; +use snapbox::prelude::*; use snapbox::str; use crate::command::util; @@ -148,23 +148,57 @@ fn integrate_smart_squash_applies_matching_change_ids() -> anyhow::Result<()> { snapbox::assert_data_eq!( env.git_log(), snapbox::str![[r#" -* 2662ee8 (HEAD -> gitbutler/workspace, A) add only-on-local +* adfadbe (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|\ +| * 2662ee8 (A) add only-on-local +|/ | * c42227a (origin/A) add only-on-remote |/ * 0dc3733 (origin/main, origin/HEAD, main) add M "#]] + .raw() ); snapbox::assert_data_eq!( raw_json_status(&env)?, snapbox::str![[r#" -status=exit status: 1 +status=exit status: 0 stdout: +{ + "uncommittedChanges": [], + "stacks": [], + "mergeBase": { + "cliId": "", + "commitId": "0dc37334a458df421bf67ea806103bf5004845dd", + "createdAt": "2000-01-01T00:00:00+00:00", + "message": "add M\n", + "authorName": "author", + "authorEmail": "author@example.com", + "conflicted": null, + "reviewId": null, + "changes": null + }, + "upstreamState": { + "behind": 0, + "latestCommit": { + "cliId": "", + "commitId": "0dc37334a458df421bf67ea806103bf5004845dd", + "createdAt": "2000-01-01T00:00:00+00:00", + "message": "add M\n", + "authorName": "author", + "authorEmail": "author@example.com", + "conflicted": null, + "reviewId": null, + "changes": null + }, + "lastFetched": null + } +} stderr: -Error: GitButler mode exit required: please run `but teardown` to preserve your work. "#]] + .raw() ); env.but("branch update A --strategy smart-squash") @@ -179,23 +213,57 @@ Updated branch A. snapbox::assert_data_eq!( env.git_log(), snapbox::str![[r#" -* bf02b24 (HEAD -> gitbutler/workspace, A) add only-on-remote +* eeb3d3c (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|\ +| * bf02b24 (A) add only-on-remote +|/ | * c42227a (origin/A) add only-on-remote |/ * 0dc3733 (origin/main, origin/HEAD, main, gitbutler/target) add M "#]] + .raw() ); snapbox::assert_data_eq!( raw_json_status(&env)?, snapbox::str![[r#" -status=exit status: 1 +status=exit status: 0 stdout: +{ + "uncommittedChanges": [], + "stacks": [], + "mergeBase": { + "cliId": "", + "commitId": "0dc37334a458df421bf67ea806103bf5004845dd", + "createdAt": "2000-01-01T00:00:00+00:00", + "message": "add M\n", + "authorName": "author", + "authorEmail": "author@example.com", + "conflicted": null, + "reviewId": null, + "changes": null + }, + "upstreamState": { + "behind": 0, + "latestCommit": { + "cliId": "", + "commitId": "0dc37334a458df421bf67ea806103bf5004845dd", + "createdAt": "2000-01-01T00:00:00+00:00", + "message": "add M\n", + "authorName": "author", + "authorEmail": "author@example.com", + "conflicted": null, + "reviewId": null, + "changes": null + }, + "lastFetched": null + } +} stderr: -Error: GitButler mode exit required: please run `but teardown` to preserve your work. "#]] + .raw() ); Ok(()) @@ -222,7 +290,7 @@ o 0dc3733 "#]]); snapbox::assert_data_eq!( - &before_log, + before_log.as_str(), snapbox::str![[r#" * a952a0b (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ @@ -236,7 +304,7 @@ o 0dc3733 .raw() ); snapbox::assert_data_eq!( - &before_status, + before_status.as_str(), snapbox::str![[r#" { "uncommittedChanges": [], diff --git a/crates/but/tests/but/command/commit.rs b/crates/but/tests/but/command/commit.rs index a8e5c1a64da..c6219c467ce 100644 --- a/crates/but/tests/but/command/commit.rs +++ b/crates/but/tests/but/command/commit.rs @@ -1,4 +1,4 @@ -use snapbox::IntoData; +use snapbox::prelude::*; use snapbox::str; use super::util; @@ -234,10 +234,10 @@ fn commit_with_branch_hint() { snapbox::assert_data_eq!( env.git_log(), snapbox::str![[r#" -* c128bce (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 8e93f22 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -| * 9477ae7 (A) add A -* | d3e2ba3 (B) add B +| * d3e2ba3 (B) add B +* | 9477ae7 (A) add A |/ * 0dc3733 (origin/main, origin/HEAD, main) add M @@ -269,10 +269,10 @@ fn commit_with_nonexistent_branch_fails() { snapbox::assert_data_eq!( env.git_log(), snapbox::str![[r#" -* c128bce (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 8e93f22 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -| * 9477ae7 (A) add A -* | d3e2ba3 (B) add B +| * d3e2ba3 (B) add B +* | 9477ae7 (A) add A |/ * 0dc3733 (origin/main, origin/HEAD, main) add M @@ -299,10 +299,10 @@ fn commit_with_create_flag_creates_new_branch() { snapbox::assert_data_eq!( env.git_log(), snapbox::str![[r#" -* c128bce (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 8e93f22 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -| * 9477ae7 (A) add A -* | d3e2ba3 (B) add B +| * d3e2ba3 (B) add B +* | 9477ae7 (A) add A |/ * 0dc3733 (origin/main, origin/HEAD, main) add M diff --git a/crates/but/tests/but/command/move.rs b/crates/but/tests/but/command/move.rs index 27d2f4789e8..6a2e7280ecf 100644 --- a/crates/but/tests/but/command/move.rs +++ b/crates/but/tests/but/command/move.rs @@ -358,10 +358,10 @@ Moved 4 commits โ†’ before [..] b_messages_after, vec![ "B: another 10 lines at the bottom", + "A: 10 lines on top", "C: add another 10 lines to new file", "C: add 10 lines to new file", "C: new file with 10 lines", - "A: 10 lines on top", "B: 10 lines at the bottom" ] ); @@ -378,10 +378,10 @@ Moved 4 commits โ†’ before [..] โ”Š โ”Šโ•ญโ”„h0 [B] โ”Šโ— [..] B: another 10 lines at the bottom +โ”Šโ— psr A: 10 lines on top โ”Šโ— zmt C: add another 10 lines to new file โ”Šโ— xkt C: add 10 lines to new file โ”Šโ— sxz C: new file with 10 lines -โ”Šโ— psr A: 10 lines on top โ”Šโ— tlv B: 10 lines at the bottom โ”œโ•ฏ โ”Š @@ -501,10 +501,10 @@ Moved 4 commits โ†’ after ynm assert_eq!( b_messages_after, vec![ + "A: 10 lines on top", "C: add another 10 lines to new file", "C: add 10 lines to new file", "C: new file with 10 lines", - "A: 10 lines on top", "B: another 10 lines at the bottom", "B: 10 lines at the bottom" ] @@ -521,10 +521,10 @@ Moved 4 commits โ†’ after ynm โ”œโ•ฏ โ”Š โ”Šโ•ญโ”„h0 [B] +โ”Šโ— psr A: 10 lines on top โ”Šโ— zmt C: add another 10 lines to new file โ”Šโ— xkt C: add 10 lines to new file โ”Šโ— sxz C: new file with 10 lines -โ”Šโ— psr A: 10 lines on top โ”Šโ— ynm B: another 10 lines at the bottom โ”Šโ— tlv B: 10 lines at the bottom โ”œโ•ฏ diff --git a/crates/but/tests/but/command/move2.rs b/crates/but/tests/but/command/move2.rs index e7291f3fe97..6502a374207 100644 --- a/crates/but/tests/but/command/move2.rs +++ b/crates/but/tests/but/command/move2.rs @@ -1845,17 +1845,17 @@ Unstacked branch 'B' .stdout_eq(snapbox::str![[r#" โ•ญโ”„zz [uncommitted] (no changes) โ”Š -โ”Šโ•ญโ”„g0 [B] -โ”Šโ— wwm add B -โ”œโ•ฏ -โ”Š -โ”Šโ•ญโ”„h0 [C] +โ”Šโ•ญโ”„g0 [C] โ”Šโ— wlx add C โ”Šโ”‚ -โ”Šโ”œโ”„i0 [A] +โ”Šโ”œโ”„h0 [A] โ”Šโ— tpm add A โ”œโ•ฏ โ”Š +โ”Šโ•ญโ”„i0 [B] +โ”Šโ— wwm add B +โ”œโ•ฏ +โ”Š โ”ด 0dc3733 (common base) 2000-01-02 add M Hint: run `but help` for all commands @@ -1906,17 +1906,17 @@ Unstacked branch 'A' .stdout_eq(snapbox::str![[r#" โ•ญโ”„zz [uncommitted] (no changes) โ”Š -โ”Šโ•ญโ”„g0 [A] -โ”Šโ— tpm add A -โ”œโ•ฏ -โ”Š -โ”Šโ•ญโ”„h0 [C] +โ”Šโ•ญโ”„g0 [C] โ”Šโ— wlx add C โ”Šโ”‚ -โ”Šโ”œโ”„i0 [B] +โ”Šโ”œโ”„h0 [B] โ”Šโ— wwm add B โ”œโ•ฏ โ”Š +โ”Šโ•ญโ”„i0 [A] +โ”Šโ— tpm add A +โ”œโ•ฏ +โ”Š โ”ด 0dc3733 (common base) 2000-01-02 add M Hint: run `but help` for all commands @@ -2020,17 +2020,17 @@ Unstacked branch 'A' .stdout_eq(snapbox::str![[r#" โ•ญโ”„zz [uncommitted] (no changes) โ”Š -โ”Šโ•ญโ”„g0 [A] -โ”Šโ— tpm add A -โ”œโ•ฏ -โ”Š -โ”Šโ•ญโ”„h0 [C] +โ”Šโ•ญโ”„g0 [C] โ”Šโ— wlx add C โ”Šโ”‚ -โ”Šโ”œโ”„i0 [B] +โ”Šโ”œโ”„h0 [B] โ”Šโ— wwm add B โ”œโ•ฏ โ”Š +โ”Šโ•ญโ”„i0 [A] +โ”Šโ— tpm add A +โ”œโ•ฏ +โ”Š โ”ด 0dc3733 (common base) 2000-01-02 add M Hint: run `but help` for all commands diff --git a/crates/but/tests/but/command/squash.rs b/crates/but/tests/but/command/squash.rs index 757b52a7298..1ab9a135846 100644 --- a/crates/but/tests/but/command/squash.rs +++ b/crates/but/tests/but/command/squash.rs @@ -1,6 +1,6 @@ use anyhow::Context as _; use bstr::ByteSlice; -use snapbox::IntoData; +use snapbox::prelude::*; use snapbox::str; use crate::{command::util, utils::Sandbox}; @@ -656,15 +656,15 @@ fn squash_branch_c_in_three_stacks_keeps_content_and_updates_graph() -> anyhow:: snapbox::assert_data_eq!( normalized_log, snapbox::str![[r#" -*-. 205e798 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +*-. 1db4ed1 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ \ -| | * a748762 (B) B: another 10 lines at the bottom -| | * 62e05ba B: 10 lines at the bottom -| * | add59d2 (A) A: 10 lines on top +| | * 930563a (C) C: add another 10 lines to new file +| | * 68a2fc3 C: add 10 lines to new file +| | * 984fd1c C: new file with 10 lines +| * | a748762 (B) B: another 10 lines at the bottom +| * | 62e05ba B: 10 lines at the bottom | |/ -* | 930563a (C) C: add another 10 lines to new file -* | 68a2fc3 C: add 10 lines to new file -* | 984fd1c C: new file with 10 lines +* / add59d2 (A) A: 10 lines on top |/ * 8f0d338 (tag: base, origin/main, origin/HEAD, main) base diff --git a/crates/but/tests/but/command/teardown.rs b/crates/but/tests/but/command/teardown.rs index 946a5f61dbd..5e777aba660 100644 --- a/crates/but/tests/but/command/teardown.rs +++ b/crates/but/tests/but/command/teardown.rs @@ -1,4 +1,4 @@ -use snapbox::IntoData; +use snapbox::prelude::*; use snapbox::str; use crate::utils::{CommandExt, Sandbox}; @@ -62,10 +62,10 @@ fn multiple_branches_preserves_state() { snapbox::assert_data_eq!( env.git_log(), snapbox::str![[r#" -* c128bce (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 8e93f22 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -| * 9477ae7 (A) add A -* | d3e2ba3 (B) add B +| * d3e2ba3 (B) add B +* | 9477ae7 (A) add A |/ * 0dc3733 (origin/main, origin/HEAD, main) add M @@ -282,12 +282,12 @@ fn two_dangling_commits_different_branches() -> anyhow::Result<()> { snapbox::assert_data_eq!( env.git_log(), snapbox::str![[r#" -* fc13bfb (HEAD -> gitbutler/workspace) add FileForB -* 091c8f9 add FileForA -* c128bce GitButler Workspace Commit +* 56624bb (HEAD -> gitbutler/workspace) add FileForB +* e021d42 add FileForA +* 8e93f22 GitButler Workspace Commit |\ -| * 9477ae7 (A) add A -* | d3e2ba3 (B) add B +| * d3e2ba3 (B) add B +* | 9477ae7 (A) add A |/ * 0dc3733 (origin/main, origin/HEAD, main) add M @@ -314,8 +314,8 @@ Exiting GitButler mode... Attempting to fix workspace stacks... โ†’ Checking for dangling commits... -โ†’ Resetting gitbutler/workspace to c128bce - โœ“ gitbutler/workspace reset to c128bce +โ†’ Resetting gitbutler/workspace to 8e93f22 + โœ“ gitbutler/workspace reset to 8e93f22 โš  Non-GitButler created commits found. โš  Undoing these commits but keeping the changes in your working directory. diff --git a/crates/but/tests/but/command/worktree.rs b/crates/but/tests/but/command/worktree.rs index 1c1676e518c..5640a4d8b89 100644 --- a/crates/but/tests/but/command/worktree.rs +++ b/crates/but/tests/but/command/worktree.rs @@ -1,4 +1,4 @@ -use snapbox::IntoData; +use snapbox::prelude::*; use snapbox::str; use crate::utils::Sandbox; @@ -11,10 +11,10 @@ fn journey_new_list_integrate() -> anyhow::Result<()> { snapbox::assert_data_eq!( env.git_log(), snapbox::str![[r#" -* c128bce (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 8e93f22 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -| * 9477ae7 (A) add A -* | d3e2ba3 (B) add B +| * d3e2ba3 (B) add B +* | 9477ae7 (A) add A |/ * 0dc3733 (origin/main, origin/HEAD, main) add M diff --git a/crates/but/tests/but/journey.rs b/crates/but/tests/but/journey.rs index eb31dd3a9d0..97ce84d0a45 100644 --- a/crates/but/tests/but/journey.rs +++ b/crates/but/tests/but/journey.rs @@ -1,4 +1,6 @@ //! Tests for various nice user-journeys, from different starting points, performing multiple common steps in sequence. +#[cfg(feature = "legacy")] +use snapbox::IntoData; use snapbox::str; use crate::utils::Sandbox; @@ -7,7 +9,7 @@ use crate::utils::Sandbox; #[test] fn from_unborn() { let env = Sandbox::open_with_default_settings("unborn"); - snapbox::assert_data_eq!(env.git_log(), snapbox::str![r""]); + snapbox::assert_data_eq!(env.git_log(), snapbox::str![""]); env.but("branch apply main") .assert() @@ -157,7 +159,6 @@ Hint: run `but branch new` to create a new branch to work on #[cfg(feature = "legacy")] #[test] fn from_workspace() { - use snapbox::IntoData; use snapbox::file; use crate::utils::CommandExt; @@ -165,10 +166,10 @@ fn from_workspace() { snapbox::assert_data_eq!( env.git_log(), snapbox::str![[r#" -* c128bce (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 8e93f22 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -| * 9477ae7 (A) add A -* | d3e2ba3 (B) add B +| * d3e2ba3 (B) add B +* | 9477ae7 (A) add A |/ * 0dc3733 (origin/main, origin/HEAD, main) add M diff --git a/crates/but/tests/fixtures/scenario/shared.sh b/crates/but/tests/fixtures/scenario/shared.sh index 8f3d8b0b35f..fec4a8254d1 100644 --- a/crates/but/tests/fixtures/scenario/shared.sh +++ b/crates/but/tests/fixtures/scenario/shared.sh @@ -74,11 +74,25 @@ function create_workspace_commit_once() { fi fi - git checkout -b gitbutler/workspace - if [ $# == 1 ] || [ $# == 0 ]; then - git commit --allow-empty -m "$workspace_commit_subject" + if [ $# -gt 1 ]; then + # Args become the workspace commit's parents, in order. + git checkout "$1" + git checkout -b gitbutler/workspace + git merge --no-ff -m "$workspace_commit_subject" "${@:2}" + # `git merge` silently drops heads that are already ancestors (and creates no commit at + # all if that leaves nothing to merge). Rebuild with every arg as a parent, in arg order, + # to match how but writes workspace commits for stacks with an empty one among them. + local expected actual + expected=$(git rev-parse "$@" | tr '\n' ' ') + actual=$(git show -s --format='%P ' HEAD) + if [ "$actual" != "$expected" ]; then + local parent_args=() p + for p in "$@"; do parent_args+=(-p "$p"); done + git reset --hard "$(git commit-tree "$(git rev-parse "HEAD^{tree}")" "${parent_args[@]}" -m "$workspace_commit_subject")" + fi else - git merge --no-ff -m "$workspace_commit_subject" "${@}" + git checkout -b gitbutler/workspace + git commit --allow-empty -m "$workspace_commit_subject" fi } diff --git a/crates/gitbutler-branch-actions/src/branch.rs b/crates/gitbutler-branch-actions/src/branch.rs index 5f5b5fd6974..3876e822c85 100644 --- a/crates/gitbutler-branch-actions/src/branch.rs +++ b/crates/gitbutler-branch-actions/src/branch.rs @@ -78,7 +78,7 @@ pub fn list_branches( &meta, but_workspace::ref_info::Options { project_meta: ctx.project_meta()?, - traversal: but_graph::init::Options::limited(), + traversal: but_graph::walk::Options::limited(), expensive_commit_info: false, gerrit_mode, }, diff --git a/crates/gitbutler-branch-actions/src/branch_manager/branch_creation.rs b/crates/gitbutler-branch-actions/src/branch_manager/branch_creation.rs index 5ed957217f9..8834f7eb7d5 100644 --- a/crates/gitbutler-branch-actions/src/branch_manager/branch_creation.rs +++ b/crates/gitbutler-branch-actions/src/branch_manager/branch_creation.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use anyhow::{Context as _, Result}; use but_ctx::{Context, access::RepoExclusive}; use gitbutler_branch::{self, BranchCreateRequest, dedup}; @@ -22,6 +24,33 @@ impl BranchManager<'_> { .list_stacks_in_workspace() .context("failed to read virtual branches")?; + // Empties must sit on distinct commits so they map to distinct workspace-commit parents โ€” + // sharing one collapses them into a single lane. Collect the commits already taken as + // workspace-commit parents (each stack's head). If the target base is already one of them, + // walk back through its ancestry to the first commit still free. The new branch then sits + // on an older base; we only log a warning about it (not yet surfaced to the client). + let repo = self.ctx.repo.get()?; + // Best-effort: a stack whose head can't be resolved just doesn't reserve a commit here. + let used_parent_commits: HashSet = all_stacks + .iter() + .filter_map(|s| s.head_oid(self.ctx).ok()) + .collect(); + let mut base_oid = target_base_oid; + while used_parent_commits.contains(&base_oid) { + base_oid = repo + .find_commit(base_oid)? + .parent_ids() + .next() + .context("Not enough commits to create a new branch")? + .detach(); + } + if base_oid != target_base_oid { + tracing::warn!( + %base_oid, + "target base commit is already a workspace-commit parent; placing the new branch on an older base" + ); + } + let stack_names: Vec = all_stacks.iter().map(|b| b.name()).collect(); let stack_name_refs: Vec<&str> = stack_names.iter().map(|s| s.as_str()).collect(); let name = dedup( @@ -45,7 +74,7 @@ impl BranchManager<'_> { } } - let branch = Stack::new_empty(self.ctx, name, target_base_oid, order)?; + let branch = Stack::new_empty(self.ctx, name, base_oid, order)?; vb_state.set_stack(branch.clone())?; ensure_stack_reference(self.ctx, &branch)?; diff --git a/crates/gitbutler-branch-actions/tests/branch-actions/virtual_branches/mod.rs b/crates/gitbutler-branch-actions/tests/branch-actions/virtual_branches/mod.rs index 5b606ae5780..ee13e54c54d 100644 --- a/crates/gitbutler-branch-actions/tests/branch-actions/virtual_branches/mod.rs +++ b/crates/gitbutler-branch-actions/tests/branch-actions/virtual_branches/mod.rs @@ -344,12 +344,17 @@ pub fn create_commit( let full_ref_name: gix::refs::FullName = format!("refs/heads/{stack_branch_name}").try_into()?; let outcome = { - let (repo, mut ws, _) = ctx.workspace_mut_and_db_with_perm(guard.write_permission())?; - let editor = but_rebase::graph_rebase::Editor::create(&mut ws, &mut meta, &repo)?; + let (repo, ws, _) = ctx.workspace_mut_and_db_with_perm(guard.write_permission())?; + let editor = but_rebase::graph_rebase::Editor::create( + ws.commit_graph(), + ws.project_meta(), + &mut meta, + &repo, + )?; but_workspace::commit::commit_create( editor, file_changes, - but_rebase::graph_rebase::mutate::RelativeToRef::Reference(full_ref_name.as_ref()), + but_rebase::graph_rebase::selector::RelativeToRef::Reference(full_ref_name.as_ref()), but_rebase::graph_rebase::mutate::InsertSide::Below, message, ctx.settings.context_lines, diff --git a/crates/gitbutler-edit-mode/src/lib.rs b/crates/gitbutler-edit-mode/src/lib.rs index 1279d0a85ad..ded72bb597e 100644 --- a/crates/gitbutler-edit-mode/src/lib.rs +++ b/crates/gitbutler-edit-mode/src/lib.rs @@ -216,14 +216,13 @@ fn workspace_from_workspace_ref(ctx: &Context) -> Result { let repo = ctx.repo.get()?; let meta = ctx.meta()?; let mut workspace_ref = open_workspace_ref(&repo)?; - let graph = but_graph::Graph::from_commit_traversal( + but_graph::Workspace::from_tip( workspace_ref.peel_to_id()?, Some(workspace_ref.inner.name.clone()), &meta, ctx.project_meta()?, - but_graph::init::Options::limited(), - )?; - graph.into_workspace() + but_graph::walk::Options::limited(), + ) } fn ensure_stack_in_workspace(ctx: &Context, stack_id: StackId) -> Result<()> { @@ -345,15 +344,19 @@ pub(crate) fn save_and_return_to_workspace(ctx: &Context, perm: &mut RepoExclusi .find_reference(WORKSPACE_BRANCH_REF)? .peel_to_commit()?; let mut meta = ctx.meta()?; - let mut workspace = but_graph::Graph::from_commit_traversal( + let workspace = but_graph::Workspace::from_tip( workspace_commit.id(), Some(gix::refs::FullName::try_from(WORKSPACE_BRANCH_REF)?), &meta, ctx.project_meta()?, - but_graph::init::Options::limited(), - )? - .into_workspace()?; - let mut editor = Editor::create(&mut workspace, &mut meta, repo)?; + but_graph::walk::Options::limited(), + )?; + let mut editor = Editor::create( + workspace.commit_graph(), + workspace.project_meta(), + &mut meta, + repo, + )?; let (target_selector, _commit) = editor.find_selectable_commit(edit_mode_metadata.commit_oid)?; diff --git a/crates/gitbutler-oplog/src/oplog.rs b/crates/gitbutler-oplog/src/oplog.rs index d3f37b4770e..7b71fd6437d 100644 --- a/crates/gitbutler-oplog/src/oplog.rs +++ b/crates/gitbutler-oplog/src/oplog.rs @@ -146,10 +146,12 @@ pub trait OplogExt { } impl OplogExt for Context { + #[instrument(skip(self, perm), err(Debug))] fn prepare_snapshot(&self, perm: &RepoShared) -> Result { prepare_snapshot(self, perm) } + #[instrument(skip(self, details, perm), err(Debug))] fn commit_snapshot( &self, snapshot_tree_id: gix::ObjectId, diff --git a/e2e/playwright/tests/branches.spec.ts b/e2e/playwright/tests/branches.spec.ts index c6fb4af0488..d4176334b5e 100644 --- a/e2e/playwright/tests/branches.spec.ts +++ b/e2e/playwright/tests/branches.spec.ts @@ -346,6 +346,10 @@ test("should be able to apply a remote branch and integrate the remote changes - await textEditorFillByTestId(page, "commit-drawer-description-input", "CCCCCCC"); await clickByTestId(page, "commit-drawer-action-button"); + // Wait for the new local commit to land before integrating; otherwise integrate + // races commit creation and can drop it, leaving 3 commits (flaky toHaveCount(4)). + await expect(commitRow(page)).toHaveCount(3); + // Integrate upstream commits on top await clickByTestId(page, "upstream-commits-integrate-button"); await waitForTestId(page, "branch-integration-apply-button"); diff --git a/packages/but-sdk/src/generated/graph/index.d.ts b/packages/but-sdk/src/generated/graph/index.d.ts index 9b5f93eb6f3..9ee8aace58e 100644 --- a/packages/but-sdk/src/generated/graph/index.d.ts +++ b/packages/but-sdk/src/generated/graph/index.d.ts @@ -1205,7 +1205,7 @@ export type Claude = { */ export type Code = "Validation" | "RepoOwnership" | "ProjectGitAuth" | "DefaultTargetNotFound" | "CommitSigningFailed" | "CommitMergeConflictFailure" | "ProjectMissing" | "AuthorMissing" | "BranchNotFound" | "SecretKeychainNotFound" | "MissingLoginKeychain" | "GitForcePushProtection" | "NetworkError" | "ProjectDatabaseIncompatible" | "DefaultTerminalNotFound" | "Unknown" | "GitNonFastForward" | "CliInstallCancelled" | "GitHubTokenExpired" | "PreconditionFailed" | "EditorExitedWithNonZeroStatus"; -/** Commit that is a part of a [`StackBranch`](gitbutler_stack::StackBranch) and, as such, containing state derived in relation to the specific branch. */ +/** Commit that is a part of a legacy `StackBranch` and, as such, containing state derived in relation to the specific branch. */ export type Commit = { /** The OID of the commit. */ id: string; @@ -2502,7 +2502,7 @@ export type Segment = { */ metadata: Branch | null; /** - * This is `true` a segment in a workspace if the entrypoint of [the traversal](but_graph::Graph::from_commit_traversal) + * This is `true` a segment in a workspace if the entrypoint of [the traversal](but_graph::Workspace::from_tip) * is this segment, and the surrounding workspace is provided for context. * * This means one will see the entire workspace, while knowing the focus is on one specific segment. diff --git a/packages/but-sdk/src/generated/linear/index.d.ts b/packages/but-sdk/src/generated/linear/index.d.ts index db9b535ab70..b79821fe54f 100644 --- a/packages/but-sdk/src/generated/linear/index.d.ts +++ b/packages/but-sdk/src/generated/linear/index.d.ts @@ -1205,7 +1205,7 @@ export type Claude = { */ export type Code = "Validation" | "RepoOwnership" | "ProjectGitAuth" | "DefaultTargetNotFound" | "CommitSigningFailed" | "CommitMergeConflictFailure" | "ProjectMissing" | "AuthorMissing" | "BranchNotFound" | "SecretKeychainNotFound" | "MissingLoginKeychain" | "GitForcePushProtection" | "NetworkError" | "ProjectDatabaseIncompatible" | "DefaultTerminalNotFound" | "Unknown" | "GitNonFastForward" | "CliInstallCancelled" | "GitHubTokenExpired" | "PreconditionFailed" | "EditorExitedWithNonZeroStatus"; -/** Commit that is a part of a [`StackBranch`](gitbutler_stack::StackBranch) and, as such, containing state derived in relation to the specific branch. */ +/** Commit that is a part of a legacy `StackBranch` and, as such, containing state derived in relation to the specific branch. */ export type Commit = { /** The OID of the commit. */ id: string; @@ -2502,7 +2502,7 @@ export type Segment = { */ metadata: Branch | null; /** - * This is `true` a segment in a workspace if the entrypoint of [the traversal](but_graph::Graph::from_commit_traversal) + * This is `true` a segment in a workspace if the entrypoint of [the traversal](but_graph::Workspace::from_tip) * is this segment, and the surrounding workspace is provided for context. * * This means one will see the entire workspace, while knowing the focus is on one specific segment. From d9c9b0e735ed8fd13f568d5f95e157a725de3369 Mon Sep 17 00:00:00 2001 From: Mattias Granlund Date: Sun, 12 Jul 2026 11:30:07 +0300 Subject: [PATCH 08/11] Delete the retired segment-graph machinery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The funeral: init/ and its post-processing, the segment container and its API surface, the old projection internals, statistics, and their test suites. 14 files, ~20k lines, none of it compiled since the switch-over (it's been undeclared โ€” CI staying green is the proof). rm-only; nothing to review beyond the file list. --- Cargo.lock | 1 - crates/but-graph/Cargo.toml | 1 - crates/but-graph/src/api.rs | 1359 --- crates/but-graph/src/init/mod.rs | 2239 ---- crates/but-graph/src/init/post.rs | 2204 ---- crates/but-graph/src/init/walk/mod.rs | 1206 --- .../src/projection/workspace/init.rs | 1489 --- .../but-graph/src/projection/workspace/mod.rs | 337 - crates/but-graph/src/segment.rs | 462 - crates/but-graph/src/statistics.rs | 196 - crates/but-graph/src/utils.rs | 263 - crates/but-graph/tests/graph/init/overlay.rs | 388 - .../tests/graph/init/with_workspace.rs | 9344 ----------------- crates/but-graph/tests/graph/merge_base.rs | 403 - crates/but-graph/tests/graph/vis.rs | 177 - .../but-graph/tests/graph/workspace/legacy.rs | 46 - 16 files changed, 20115 deletions(-) delete mode 100644 crates/but-graph/src/api.rs delete mode 100644 crates/but-graph/src/init/mod.rs delete mode 100644 crates/but-graph/src/init/post.rs delete mode 100644 crates/but-graph/src/init/walk/mod.rs delete mode 100644 crates/but-graph/src/projection/workspace/init.rs delete mode 100644 crates/but-graph/src/projection/workspace/mod.rs delete mode 100644 crates/but-graph/src/segment.rs delete mode 100644 crates/but-graph/src/statistics.rs delete mode 100644 crates/but-graph/src/utils.rs delete mode 100644 crates/but-graph/tests/graph/init/overlay.rs delete mode 100644 crates/but-graph/tests/graph/init/with_workspace.rs delete mode 100644 crates/but-graph/tests/graph/merge_base.rs delete mode 100644 crates/but-graph/tests/graph/vis.rs delete mode 100644 crates/but-graph/tests/graph/workspace/legacy.rs diff --git a/Cargo.lock b/Cargo.lock index 99181a463dc..67470a04873 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1329,7 +1329,6 @@ dependencies = [ "but-testsupport", "gitbutler-reference", "gix", - "itertools", "snapbox", "termtree", "tracing", diff --git a/crates/but-graph/Cargo.toml b/crates/but-graph/Cargo.toml index b1c8187baca..5d3ae887bac 100644 --- a/crates/but-graph/Cargo.toml +++ b/crates/but-graph/Cargo.toml @@ -23,7 +23,6 @@ anyhow.workspace = true bitflags.workspace = true boolean-enums.workspace = true tracing.workspace = true -itertools.workspace = true [dev-dependencies] but-meta = { workspace = true, features = ["legacy"] } diff --git a/crates/but-graph/src/api.rs b/crates/but-graph/src/api.rs deleted file mode 100644 index 2efa2943fb2..00000000000 --- a/crates/but-graph/src/api.rs +++ /dev/null @@ -1,1359 +0,0 @@ -use std::{ - cmp::Reverse, - collections::{BTreeSet, BinaryHeap}, - ops::{Deref, Index, IndexMut}, -}; - -use anyhow::{Context as _, bail, ensure}; -use petgraph::{ - Direction, - prelude::EdgeRef, - stable_graph::EdgeReference, - visit::{IntoEdgeReferences, NodeIndexable, Visitable}, -}; - -use crate::{ - Commit, CommitFlags, CommitIndex, Edge, EntryPoint, EntryPointCommit, Graph, Segment, - SegmentFlags, SegmentIndex, SegmentRelation, StopCondition, - init::PetGraph, - utils::{SegmentTable, SegmentVisitScratch}, - workspace::commit::is_managed_workspace_by_message, -}; - -boolean_enums::gen_boolean_enum!(pub FirstParent); - -/// Mutation -impl Graph { - /// Insert `segment` to the graph so that it's not connected to any other segment, and return its index. - /// - /// Note that as a side effect, the [entrypoint](Self::entrypoint()) will also be set if it's not - /// set yet. - pub fn insert_segment_set_entrypoint(&mut self, segment: Segment) -> SegmentIndex { - let entrypoint = segment - .commits - .first() - .map(|commit| EntryPointCommit::AtCommit(commit.id)) - .unwrap_or(EntryPointCommit::Unborn); - let index = self.insert_segment(segment); - if self.entrypoint.is_none() { - self.entrypoint = Some((index, entrypoint)) - } - index - } - - /// Insert `segment` to the graph so that it's not connected to any other segment, and return its index. - pub fn insert_segment(&mut self, segment: Segment) -> SegmentIndex { - let index = self.inner.add_node(segment); - self.inner[index].id = index; - index - } - - /// Put `dst` on top of `src`, connecting it from the `src_commit` specifically, - /// an index valid for [`Segment::commits`] in `src` to the commit at `dst_commit` in `dst`. - /// - /// If `src_commit` is `None`, there must be no commit in `base` and it's connected directly, - /// something that can happen for the root base of the graph which is usually empty. - /// This is as if a tree would be growing upwards, but it's a matter of perspective really, there - /// is no up and down. - /// - /// `dst_commit_id` can be provided if the connection is to a future commit that isn't yet available - /// in the `segment`. If `None`, it will be looked up in the `segment` itself. - /// - /// `parent_order` is the 0-based position of `dst_commit_id` among the source commit's - /// parents. For a merge commit with parents `[A, B]`, the edge to `A` must use `0`, - /// and the edge to `B` must use `1`, even if traversal discovers `B` before `A`. - /// - /// Return the newly added segment. - pub fn connect_new_segment( - &mut self, - src: SegmentIndex, - src_commit: impl Into>, - dst: Segment, - dst_commit: impl Into>, - dst_commit_id: impl Into>, - parent_order: u32, - ) -> SegmentIndex { - let dst = self.inner.add_node(dst); - self.inner[dst].id = dst; - self.connect_segments_with_ids( - src, - src_commit, - None, - dst, - dst_commit, - dst_commit_id.into(), - parent_order, - ); - dst - } -} - -/// Merge-base computation -impl Graph { - /// Determine the ancestry relationship of `a` relative to `b`. - /// - /// `Ancestor` means `a` is reachable from `b` when walking towards history, - /// `Descendant` means the inverse, and `Diverged` means they share history - /// but neither is ancestor of the other. - pub fn relation_between(&self, a: SegmentIndex, b: SegmentIndex) -> SegmentRelation { - if a == b { - return SegmentRelation::Identity; - } - - match self.find_merge_base(a, b) { - Some(base) if base == a => SegmentRelation::Ancestor, - Some(base) if base == b => SegmentRelation::Descendant, - Some(_) => SegmentRelation::Diverged, - None => SegmentRelation::Disjoint, - } - } - - /// Like [`Self::relation_between()`], but takes object ids of commits. - pub fn relation_between_by_commit_id( - &self, - commit_a: gix::ObjectId, - commit_b: gix::ObjectId, - ) -> anyhow::Result { - let a = self.segment_id_by_commit_id(commit_a)?; - let b = self.segment_id_by_commit_id(commit_b)?; - Ok(self.relation_between(a, b)) - } - - /// Compute the merge-base just like Git would between segments `a` and `b`, but finding all possible merge-bases of a walk, - /// which are then truncated to the highest merge-base that includes all the other merge-bases. - /// - /// Note that this implementation isn't 'stable' and different orders of inputs can change the outcome. - /// - /// Returns `None` if there is no merge-base as `a` and `b` don't share history. - /// If `a == b`, `Some(a)` is returned immediately. - pub fn find_merge_base(&self, a: SegmentIndex, b: SegmentIndex) -> Option { - if a == b { - return Some(a); - } - - let mut flags = SegmentTable::new(self.inner.node_bound(), SegmentFlags::empty()); - let bases = self.paint_down_to_common(a, b, &mut flags); - - if bases.is_empty() { - return None; - } - - let result = self.remove_redundant(&bases, &mut flags); - result.first().copied() - } - - /// Like [`Self::find_merge_base()`], but takes object ids of commits, - /// returning the id of the commit that is the merge-base. - pub fn find_merge_base_by_commit_id( - &self, - commit_a: gix::ObjectId, - commit_b: gix::ObjectId, - ) -> anyhow::Result> { - let a = self.segment_id_by_commit_id(commit_a)?; - let b = self.segment_id_by_commit_id(commit_b)?; - self.find_merge_base(a, b) - .map(|base| self.commit_id_by_segment(base)) - .transpose() - } - - /// Return all commits reachable from `included`, but not reachable from `excluded`. - /// - /// This is equivalent to the reachable set of `excluded..included`, with segment ids - /// instead of rev-specs. The returned commits follow the graph traversal order from - /// `included` towards history. - /// - /// Unlike Git's revision walk, this does not sort pending commits by date or enforce - /// global topo-order across commits. It walks segments by their graph generation, - /// emits each segment's commits in stored tip-to-base order, and lazily paints the - /// excluded side only as far as needed to prove emitted segments are not hidden. - /// - /// If `first_parent` is [`FirstParent::Yes`], both the included and excluded traversals follow - /// only segment edges with `parent_order == 0`. - pub fn find_commits_reachable_from_a_not_b( - &self, - included: SegmentIndex, - excluded: SegmentIndex, - first_parent: FirstParent, - ) -> Vec<&Commit> { - self.find_segments_reachable_from_a_not_b(included, excluded, first_parent) - .flat_map(|segment| segment.commits.iter()) - .collect() - } - - /// Return all segments reachable from `included`, but not reachable from `excluded`. - /// - /// This is equivalent to the reachable set of `excluded..included`, with segment ids - /// instead of rev-specs. The returned segments follow the graph traversal order from - /// `included` towards history. - /// - /// Unlike Git's revision walk, this does not sort pending commits by date or enforce - /// global topo-order across commits. It walks segments by their graph generation, - /// emits each segment's commits in stored tip-to-base order, and lazily paints the - /// excluded side only as far as needed to prove emitted segments are not hidden. - /// - /// If `first_parent` is [`FirstParent::Yes`], both the included and excluded traversals follow - /// only segment edges with `parent_order == 0`. - pub fn find_segments_reachable_from_a_not_b( - &self, - included: SegmentIndex, - excluded: SegmentIndex, - first_parent: FirstParent, - ) -> impl Iterator { - let first_parent: bool = first_parent.into(); - let mut flags = SegmentTable::new(self.inner.node_bound(), SegmentFlags::empty()); - let mut queue = BinaryHeap::new(); - let mut sequence = 0; - self.queue_segment_for_reachable_difference( - included, - false, - &mut flags, - &mut queue, - &mut sequence, - ); - self.queue_segment_for_reachable_difference( - excluded, - true, - &mut flags, - &mut queue, - &mut sequence, - ); - - let mut segments = Vec::new(); - let mut max_emitted_generation = None; - while let Some((_, _, is_excluded, segment_id)) = queue.pop() { - if is_excluded { - let parent_ids = - self.parent_segments_for_reachable_difference(segment_id, first_parent); - for parent_id in parent_ids { - self.queue_segment_for_reachable_difference( - parent_id, - true, - &mut flags, - &mut queue, - &mut sequence, - ); - } - if self.excluded_frontier_is_past_emitted_segments( - &queue, - &flags, - max_emitted_generation, - ) { - break; - } - continue; - } - - if flags.get(segment_id).contains(SegmentFlags::SEGMENT2) { - continue; - } - - let generation = self[segment_id].generation; - max_emitted_generation = - Some(max_emitted_generation.map_or(generation, |max| max.max(generation))); - segments.push(segment_id); - let parent_ids = - self.parent_segments_for_reachable_difference(segment_id, first_parent); - for parent_id in parent_ids { - self.queue_segment_for_reachable_difference( - parent_id, - false, - &mut flags, - &mut queue, - &mut sequence, - ); - } - } - - segments - .into_iter() - .filter(move |segment_id| !flags.get(*segment_id).contains(SegmentFlags::SEGMENT2)) - .map(|segment_id| &self[segment_id]) - } - - /// Queue `segment_id` for the reachable-difference walk if this side has not seen it yet. - /// - /// `SEGMENT1` tracks the included side, `SEGMENT2` tracks the excluded side, and `STALE` - /// is reused here as the excluded-side queued/processed bit. Included segments that are - /// already known to be excluded are not queued because they cannot contribute to the result. - /// Queue keys sort by ascending generation and then insertion order so both frontiers advance - /// from tips toward history in a deterministic segment order. - fn queue_segment_for_reachable_difference( - &self, - segment_id: SegmentIndex, - is_excluded: bool, - flags: &mut SegmentTable, - queue: &mut BinaryHeap<(Reverse, Reverse, bool, SegmentIndex)>, - sequence: &mut usize, - ) { - let segment_flags = flags.get_mut(segment_id); - if is_excluded { - segment_flags.insert(SegmentFlags::SEGMENT2); - if segment_flags.contains(SegmentFlags::STALE) { - return; - } - segment_flags.insert(SegmentFlags::STALE); - } else { - if segment_flags.intersects(SegmentFlags::SEGMENT1 | SegmentFlags::SEGMENT2) { - return; - } - segment_flags.insert(SegmentFlags::SEGMENT1); - } - - queue.push(( - Reverse(self[segment_id].generation), - Reverse(*sequence), - is_excluded, - segment_id, - )); - *sequence += 1; - } - - /// Return the parent segments that should be traversed from `segment_id`. - /// - /// In all-parent mode this returns every outgoing segment edge. In first-parent mode it - /// returns only edges whose destination is the source commit's first parent. - fn parent_segments_for_reachable_difference( - &self, - segment_id: SegmentIndex, - first_parent_only: bool, - ) -> impl Iterator { - self.inner - .edges_directed(segment_id, Direction::Outgoing) - .filter(move |edge| !first_parent_only || edge.weight().parent_order == 0) - .map(|edge| edge.target()) - } - - /// Return `true` once the excluded-side frontier cannot still hide any segment already emitted. - /// - /// Segment generations are topological: edges point from lower generations toward higher - /// generations. After all non-hidden included work has left the queue, an excluded frontier - /// whose minimum generation is greater than the maximum emitted generation can only paint - /// deeper ancestors, not any emitted segment. At that point the caller may stop walking the - /// excluded side and filter the segments that were already marked hidden. - fn excluded_frontier_is_past_emitted_segments( - &self, - queue: &BinaryHeap<(Reverse, Reverse, bool, SegmentIndex)>, - flags: &SegmentTable, - max_emitted_generation: Option, - ) -> bool { - if queue.iter().any(|(_, _, is_excluded, segment_id)| { - !*is_excluded && !flags.get(*segment_id).contains(SegmentFlags::SEGMENT2) - }) { - return false; - } - - let Some(max_emitted_generation) = max_emitted_generation else { - return true; - }; - let Some(min_excluded_generation) = queue - .iter() - .filter_map(|(_, _, is_excluded, segment_id)| { - is_excluded.then_some(self[*segment_id].generation) - }) - .min() - else { - return true; - }; - - min_excluded_generation > max_emitted_generation - } - - /// Return all commit ids reachable from `included`, but not reachable from `excluded`. - /// - /// This is a convenience wrapper around - /// [`Self::find_segments_reachable_from_a_not_b()`], taking object ids of commits. - /// If `first_parent` is [`FirstParent::Yes`], both traversals follow only first-parent edges. - pub fn find_commit_ids_reachable_from_a_not_b( - &self, - included: gix::ObjectId, - excluded: gix::ObjectId, - first_parent: FirstParent, - ) -> anyhow::Result> { - let included = self.segment_id_by_commit_id(included)?; - let excluded = self.segment_id_by_commit_id(excluded)?; - Ok(self - .find_commits_reachable_from_a_not_b(included, excluded, first_parent) - .into_iter() - .map(|commit| commit.id) - .collect()) - } - - /// Compute an octopus merge-base from multiple `segments`. - /// - /// The first segment becomes the initial candidate. Each following segment is - /// folded into that candidate by computing their pairwise - /// [`Self::find_merge_base()`]. If any pair does not share history, there - /// is no merge-base common to all segments. - /// - /// Returns `None` if `segments` is empty. - /// If `segments` has one element, it returns that. - pub fn find_merge_base_octopus( - &self, - segments: impl IntoIterator, - ) -> Option { - let mut segments = segments.into_iter(); - let first = segments.next()?; - segments.try_fold(first, |base, segment| self.find_merge_base(base, segment)) - } - - /// Like [`Self::find_merge_base_octopus()`], but works with object ids of `commits`, - /// returning the id of the commit that is the merge-base. - pub fn find_merge_base_octopus_by_commit_id( - &self, - commits: impl IntoIterator, - ) -> anyhow::Result> { - let mut segments = Vec::new(); - for commit_id in commits { - segments.push(self.segment_id_by_commit_id(commit_id)?); - } - self.find_merge_base_octopus(segments) - .map(|base| self.commit_id_by_segment(base)) - .transpose() - } - - /// Return `(commit, owner_sidx_of_commit)` for `start` as long as it can unambiguously be attributed - /// to belong to the segment at `start` even if it doesn't own it. - /// - /// Empty virtual segments can be considered to *point* to a commit even if they don't own it as - /// long as it can be found by following the only outgoing edge of `start` and subsequent - /// segments. This lets real refs resolve even when another segment was prioritized to own the - /// shared commit. - /// - /// This helper intentionally stops at ambiguous segments with more than one outgoing connection. - pub fn resolve_to_unambiguously_pointed_to_commit( - &self, - start: SegmentIndex, - ) -> Option<(&crate::Commit, SegmentIndex)> { - if let Some(commit) = self[start].commits.first() { - return Some((commit, start)); - } - - let mut current = start; - let mut seen = BTreeSet::new(); // SeenTable isn't worth it here. - while seen.insert(current) { - let mut parents = self.inner.neighbors_directed(current, Direction::Outgoing); - let Some(parent) = parents.next() else { - tracing::warn!( - start = start.index(), - current = current.index(), - "Could not resolve empty segment as it has no outgoing parent segment" - ); - return None; - }; - if parents.next().is_some() { - tracing::warn!( - start = start.index(), - current = current.index(), - "Could not resolve empty segment as it has multiple outgoing parent segments" - ); - return None; - } - - current = parent; - if let Some(commit) = self[current].commits.first() { - return Some((commit, current)); - } - } - tracing::warn!( - start = start.index(), - current = current.index(), - "Could not resolve empty segment as traversal ended, there were only empty segments or none at all" - ); - None - } - - fn commit_id_by_segment(&self, segment: SegmentIndex) -> anyhow::Result { - self.tip_skip_empty(segment) - .map(|commit| commit.id) - .with_context(|| { - format!("BUG: Segment {segment:?} does not contain a reachable tip commit") - }) - } - - /// Return the id of the segment that owns `commit_id`, or error if it wasn't found. - /// That is unexpected as the traversal is supposed to find all commits of interest. - pub fn segment_id_by_commit_id( - &self, - commit_id: gix::ObjectId, - ) -> anyhow::Result { - self.segment_by_commit_id(commit_id).map(|s| s.id) - } - - /// Return the segment that owns `commit_id`, or error if it wasn't found. - /// That is unexpected as the traversal is supposed to find all commits of interest. - pub fn segment_by_commit_id(&self, commit_id: gix::ObjectId) -> anyhow::Result<&Segment> { - self.inner - .node_weights() - .find(|s| s.commits.iter().any(|c| c.id == commit_id)) - .with_context(|| { - format!("Commit {commit_id} not found in any segment, it wasn't traversed") - }) - } - - /// Paint segments reachable from `first` with SEGMENT1 and from `second` with SEGMENT2. - /// When a segment has both flags, it's a potential merge-base. - /// Returns all potential merge-bases with their generation numbers. - fn paint_down_to_common( - &self, - first: SegmentIndex, - second: SegmentIndex, - flags: &mut SegmentTable, - ) -> Vec<(SegmentIndex, usize)> { - // Priority queue ordered by generation (higher generation = closer to root = lower priority). - // We use Reverse because BinaryHeap is a max-heap and we want segments with *lower* generation - // (i.e. closer to tips) to be processed first. - let mut queue: BinaryHeap<(Reverse, SegmentIndex)> = BinaryHeap::new(); - - // Initialize first segment - let first_flags = flags.get_mut(first); - *first_flags |= SegmentFlags::SEGMENT1; - queue.push((Reverse(self[first].generation), first)); - - // Initialize second segment - let second_flags = flags.get_mut(second); - *second_flags |= SegmentFlags::SEGMENT2; - queue.push((Reverse(self[second].generation), second)); - - let mut out = Vec::new(); - - // Keep processing while there are potentially useful entries. - // - // Stale entries still need to propagate their stale marker to their - // parents if other non-stale queue entries remain. Once everything left - // in the queue is stale, no better merge-base can be found. - while queue - .iter() - .any(|(_, segment_id)| !flags.get(*segment_id).contains(SegmentFlags::STALE)) - { - let Some((Reverse(generation), segment_id)) = queue.pop() else { - break; - }; - let segment_flags = flags.get(segment_id); - - let mut flags_without_result = segment_flags - & (SegmentFlags::SEGMENT1 | SegmentFlags::SEGMENT2 | SegmentFlags::STALE); - - // If reachable from both sides, it's a merge-base candidate - if flags_without_result == (SegmentFlags::SEGMENT1 | SegmentFlags::SEGMENT2) { - if !segment_flags.contains(SegmentFlags::RESULT) { - flags.get_mut(segment_id).insert(SegmentFlags::RESULT); - out.push((segment_id, generation)); - } - flags_without_result |= SegmentFlags::STALE; - } - - // Propagate flags to parents (outgoing direction = towards history) - for parent_id in self - .inner - .neighbors_directed(segment_id, Direction::Outgoing) - { - let parent_flags = flags.get_mut(parent_id); - if (*parent_flags & flags_without_result) != flags_without_result { - *parent_flags |= flags_without_result; - queue.push((Reverse(self[parent_id].generation), parent_id)); - } - } - } - - out - } - - /// Remove all those segments from `segments` if they are in the history of another segment in `segments`. - /// That way, we return only the topologically most recent segments in `segments`. - fn remove_redundant( - &self, - segments: &[(SegmentIndex, usize)], - flags: &mut SegmentTable, - ) -> Vec { - if segments.is_empty() { - return Vec::new(); - } - - // Clear flags for the redundancy check - flags.clear(); - - let sorted_segments = { - let mut v = segments.to_vec(); - // Sort by generation ascending (lower generation first = closer to tips) - v.sort_by_key(|(_, generation)| *generation); - v - }; - - let mut min_gen_pos = 0; - let mut min_gen = sorted_segments[min_gen_pos].1; - - let mut walk_start: Vec<(SegmentIndex, usize)> = Vec::with_capacity(segments.len()); - - // Mark all input segments with RESULT and collect their parents for walking - for (sidx, _) in segments { - flags.get_mut(*sidx).insert(SegmentFlags::RESULT); - - for parent_id in self.inner.neighbors_directed(*sidx, Direction::Outgoing) { - let parent_flags = flags.get_mut(parent_id); - // Prevent double-addition - if !parent_flags.contains(SegmentFlags::STALE) { - parent_flags.insert(SegmentFlags::STALE); - walk_start.push((parent_id, self[parent_id].generation)); - } - } - } - - walk_start.sort_by_key(|(sidx, _)| sidx.index()); - - // Allow walking everything at first (remove STALE from walk_start entries) - for (sidx, _) in &walk_start { - flags.get_mut(*sidx).remove(SegmentFlags::STALE); - } - - let mut count_still_independent = segments.len(); - let mut stack: Vec<(SegmentIndex, usize)> = Vec::new(); - - while let Some((segment_id, segment_gen)) = walk_start.pop() { - if count_still_independent <= 1 { - break; - } - - stack.clear(); - flags.get_mut(segment_id).insert(SegmentFlags::STALE); - stack.push((segment_id, segment_gen)); - - while let Some((current_id, current_gen)) = stack.last().copied() { - let current_flags = flags.get(current_id); - - if current_flags.contains(SegmentFlags::RESULT) { - flags.get_mut(current_id).remove(SegmentFlags::RESULT); - count_still_independent -= 1; - - if count_still_independent <= 1 { - break; - } - - // Update min_gen if we just removed the minimum - if current_id == sorted_segments[min_gen_pos].0 { - while min_gen_pos < segments.len() - 1 - && flags - .get(sorted_segments[min_gen_pos].0) - .contains(SegmentFlags::STALE) - { - min_gen_pos += 1; - } - min_gen = sorted_segments[min_gen_pos].1; - } - } - - // Skip if generation is below minimum - if current_gen > min_gen { - stack.pop(); - continue; - } - - let previous_len = stack.len(); - - for parent_id in self - .inner - .neighbors_directed(current_id, Direction::Outgoing) - { - let parent_flags = flags.get_mut(parent_id); - if !parent_flags.contains(SegmentFlags::STALE) { - parent_flags.insert(SegmentFlags::STALE); - stack.push((parent_id, self[parent_id].generation)); - } - } - - if previous_len == stack.len() { - stack.pop(); - } - } - } - - // Return segments that are not marked as STALE - segments - .iter() - .filter_map(|(sidx, _)| { - (!flags.get(*sidx).contains(SegmentFlags::STALE)).then_some(*sidx) - }) - .collect() - } -} - -/// # Points of interest -impl Graph { - /// Return the entry-point commit of this graph if it is a - /// [managed](is_managed_workspace_by_message) workspace commit. - /// - /// Note that managed workspace commits are owned by GitButler. - /// The `repo` is used to look up the entrypoint commit and to obtain its message - /// and only return it if it seems to be owned by GitButler. - pub fn managed_entrypoint_commit( - &self, - repo: &gix::Repository, - ) -> anyhow::Result> { - let Some(ec) = self.entrypoint()?.commit() else { - return Ok(None); - }; - - let commit = repo.find_commit(ec.id)?; - let message = commit.message_raw()?; - Ok(is_managed_workspace_by_message(message).then_some(ec)) - } - - /// 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. - /// - /// Note that this method only fails if the entrypoint wasn't set correctly due to a bug. - pub fn entrypoint(&self) -> anyhow::Result> { - let (segment_index, commit) = self - .entrypoint - .context("BUG: must always set the entrypoint")?; - let segment = self.inner.node_weight(segment_index).with_context(|| { - format!("BUG: entrypoint segment at {segment_index:?} wasn't present") - })?; - let commit_and_owner = match commit { - EntryPointCommit::Unborn => None, - EntryPointCommit::AtCommit(id) => { - // We don't check invariants here and are more flexible than we have to, - // validation takes care of the details. - if let Some(t) = segment.commit_by_id(id).map(|c| (c, segment)) { - Some(t) - } else { - let owner = self.segment_by_commit_id(id)?; - let commit = owner.commit_by_id(id) - .with_context(|| { - format!( - "BUG: owner segment {owner_id:?} did not contain remembered entrypoint commit {id}", - owner_id = owner.id - ) - })?; - Some((commit, owner)) - } - } - }; - Ok(EntryPoint { - segment, - commit_and_owner, - }) - } -} - -/// Query -/// โ€ผ๏ธUseful only if one knows the graph traversal was started where one expects, or else the graph may be partial. -impl Graph { - /// Return the `(segment, commit)` that is either named `name`, - /// or has a commit with `name` in its [refs](Commit::refs). - /// The returned `commit` is the commit at which the reference with `name` - /// is pointing to directly or indirectly. - /// - /// Note that tags may or may not be included in the graph, depending on how it was created. - /// - /// ### Performance - /// - /// This is a brute-force search through all nodes and all data in the graph - beware of hot-loop usage. - pub fn segment_and_commit_by_ref_name( - &self, - name: &gix::refs::FullNameRef, - ) -> Option<(&Segment, &Commit)> { - self.inner.node_weights().find_map(|s| { - if s.ref_name().is_some_and(|rn| rn == name) { - self.tip_skip_empty(s.id).map(|c| (s, c)) - } else { - s.commits.iter().find_map(|c| { - c.refs - .iter() - .any(|ri| ri.ref_name.as_ref() == name) - .then_some((s, c)) - }) - } - }) - } - - /// Return the segment that is named `name`, - /// - /// Note that tags may or may not be included in the graph, depending on how it was created. - /// - /// ### Performance - /// - /// This is a brute-force search through all nodes and all data in the graph - beware of hot-loop usage. - pub fn segment_by_ref_name(&self, name: &gix::refs::FullNameRef) -> Option<&Segment> { - self.inner - .node_weights() - .find(|s| s.ref_name().is_some_and(|rn| rn == name)) - } - - /// Starting at `segment`, return the commit it owns, or the commit it - /// unambiguously points to through empty segments. - /// - /// Empty virtual segments can stand in for refs whose commit is owned by a - /// different segment. This follows the only outgoing connection through any - /// subsequent empty segments until it reaches the first non-empty segment. - /// - /// Returns `None` if any empty segment on the path has zero or multiple - /// outgoing connections, as there is no unambiguous commit to return. - pub fn tip_skip_empty(&self, segment: SegmentIndex) -> Option<&Commit> { - self.resolve_to_unambiguously_pointed_to_commit(segment) - .map(|(c, _)| c) - } - - /// Visit the ancestry of `start` along the first parents, itself excluded, until `stop` returns `true`. - /// Also return the segment that we stopped at. - /// **Important**: `stop` is not called with `start`, this is a feature. - pub fn visit_segments_downward_along_first_parent_exclude_start( - &self, - start: SegmentIndex, - stop: impl FnMut(&Segment) -> bool, - ) { - self.visit_segments_downward_along_first_parent(start, false, stop); - } - - /// Visit the ancestry of `start` along the first parents, including `start`, until `stop` returns `true`. - pub fn visit_segments_downward_along_first_parent_include_start( - &self, - start: SegmentIndex, - stop: impl FnMut(&Segment) -> bool, - ) { - self.visit_segments_downward_along_first_parent(start, true, stop); - } - - fn visit_segments_downward_along_first_parent( - &self, - start: SegmentIndex, - include_start: bool, - mut stop: impl FnMut(&Segment) -> bool, - ) -> Option { - let mut next = if include_start { - Some(start) - } else { - self.inner - .edges_directed(start, Direction::Outgoing) - .next() - .map(|edge| edge.target()) - }; - let mut seen = self.seen_table(); - while let Some(sidx) = next { - let segment = &self[sidx]; - if stop(segment) { - return Some(sidx); - } - next = if seen.insert_unseen(sidx) { - self.inner - .edges_directed(sidx, Direction::Outgoing) - .next() - .map(|edge| edge.target()) - } else { - None - }; - } - None - } - - /// Return `true` if this graph is possibly partial as the hard limit was hit, - /// meaning that the core traversal algorithm stopped queueing new commits without - /// necessarily satisfying all constraints. - /// - /// Such a graph is possibly partial, which can affect algorithms - /// relying on it being complete. - pub fn hard_limit_hit(&self) -> bool { - self.hard_limit_hit - } - - /// Claim that the graph was pruned without regard to the core graph algorithm. - pub fn set_hard_limit_hit(&mut self) { - self.hard_limit_hit = true; - } - - /// Lookup the segment of `sidx` and then find its sibling segment, if it has one. - pub fn lookup_sibling_segment(&self, sidx: SegmentIndex) -> Option<&Segment> { - self.inner.node_weight(sidx)?.sibling_segment(self) - } - - /// Lookup the segment of `sidx` and then find its remote tracking branch segment, if it has one. - pub fn lookup_remote_tracking_branch_segment(&self, sidx: SegmentIndex) -> Option<&Segment> { - self.inner.node_weight( - self.inner - .node_weight(sidx)? - .remote_tracking_branch_segment_id?, - ) - } - - /// Return all segments which have no other segments *above* them, making them tips. - /// - /// Typically, there is only one, but there *can* be multiple technically. - pub fn tip_segments(&self) -> impl Iterator { - self.inner.externals(Direction::Incoming) - } - - /// Return all segments which have no other segments *below* them, making them bases. - /// - /// Typically, there is only one, but there can easily be multiple. - pub fn base_segments(&self) -> impl Iterator { - 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| self.is_partial_segment(*s)) - } - - /// Return `true` if the segment behind `sidx` - /// isn't fully defined as traversal stopped due to some abort condition. - /// Valid partial segments always have at least one commit. - fn is_partial_segment(&self, sidx: SegmentIndex) -> bool { - self.stop_condition(sidx) - .is_some_and(|condition| condition.is_unnatural()) - } - - /// 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**. - /// Note that a single `CommitIndex` can link to multiple segments, as happens with merge-commits. - /// - /// Thus, a [`CommitIndex`] of `0` indicates the paired segment sits directly on top of `sidx`, probably as part of - /// a merge commit that is the last commit in the respective segment. The index is always valid in the - /// [`Segment::commits`] field of `sidx`. - pub fn segments_below_in_order( - &self, - sidx: SegmentIndex, - ) -> impl Iterator, SegmentIndex)> { - self.inner - .edges_directed(sidx, Direction::Outgoing) - .filter_map(|edge| { - let dst = edge.weight().dst; - dst.is_none_or(|dst| dst == 0) - .then_some((edge.weight().src, edge.target())) - }) - } - - /// Return the condition under which traversal stopped at `sidx`, - /// or `None` if the traversal didn't stop. - pub fn stop_condition(&self, sidx: SegmentIndex) -> Option { - if self - .inner - .edges_directed(sidx, Direction::Outgoing) - .next() - .is_some() - { - return None; - } - let commit = self[sidx].commits.last()?; - let mut condition = StopCondition::empty(); - if commit.parent_ids.is_empty() { - condition |= StopCondition::FirstCommit; - } - if commit.flags.contains(CommitFlags::ShallowBoundary) { - condition |= StopCondition::ShallowBoundary; - } - if !commit.parent_ids.is_empty() && !condition.contains(StopCondition::ShallowBoundary) { - condition |= StopCondition::Limit; - } - (!condition.is_empty()).then_some(condition) - } - - /// Return the number of segments stored within the graph. - pub fn num_segments(&self) -> usize { - self.inner.node_count() - } - - /// Return the number of edges that are connecting segments. - pub fn num_connections(&self) -> usize { - self.inner.edge_count() - } - - /// Return the number of commits in all segments. - pub fn num_commits(&self) -> usize { - self.inner - .node_indices() - .map(|n| self[n].commits.len()) - .sum::() - } - - /// Return an iterator over all indices of segments in the graph. - pub fn segments(&self) -> impl Iterator { - self.inner.node_indices() - } - - /// Visit all segments, including `start`, until `visit_and_prune(segment)` returns `true`. - /// Pruned segments aren't returned and not traversed, but note that `visit_and_prune` may - /// be called multiple times until the traversal stops. - pub fn visit_all_segments_including_start_until( - &self, - start: SegmentIndex, - direction: Direction, - visit_and_prune: impl FnMut(&Segment) -> bool, - ) { - let mut scratch = SegmentVisitScratch::new(self); - scratch.visit_including_start_until(self, start, direction, visit_and_prune); - } - - /// Visit all segments, excluding `start`, until `visit_and_prune(segment)` returns `true`. - /// Pruned segments aren't returned and not traversed, but note that `visit_and_prune` may - /// be called multiple times until the traversal stops. - pub fn visit_all_segments_excluding_start_until( - &self, - start: SegmentIndex, - direction: Direction, - visit_and_prune: impl FnMut(&Segment) -> bool, - ) { - let mut scratch = SegmentVisitScratch::new(self); - scratch.visit_excluding_start_until(self, start, direction, visit_and_prune); - } -} - -/// Query -/// -/// The query relies on the segmentation of the graph being as advertised, something we assure as part -/// of the initial creation. -impl Graph { - /// Return a utility to perform topological walks on the graph. - pub fn topo_walk(&self) -> petgraph::visit::Topo::Map> { - petgraph::visit::Topo::new(&self.inner) - } -} - -/// Validation -impl Graph { - /// Validate the graph for consistency and fail loudly when an issue was found. - /// Use this before using the graph for anything serious, but particularly in testing. - /// Final-graph invariants are skipped if the traversal hit the hard limit, - /// as these graphs are explicitly partial. - pub fn validated(self) -> anyhow::Result { - if !self.hard_limit_hit { - self.check_entrypoint_invariants()?; - for segment_index in self.inner.node_indices() { - let segment = &self.inner[segment_index]; - let outgoing = self - .inner - .neighbors_directed(segment_index, Direction::Outgoing) - .count(); - self.check_virtual_segments_are_empty_and_connected( - segment_index, - segment, - outgoing, - )?; - self.check_multi_parent_tip_or_ancestor_segments_have_commits( - segment_index, - segment, - outgoing, - )?; - } - for tip in &self.traversal_tips { - self.check_traversal_tip_points_to_first_commit(tip)?; - } - } - for edge in self.inner.edge_references() { - Self::check_edge(&self.inner, edge, false)?; - } - Ok(self) - } - - /// Validate the graph for consistency and return all errors. - /// - /// If the graph didn't hit the hard limit, this checks: - /// - /// - The graph entrypoint exists, points to an existing segment, matches the - /// remembered entrypoint ref when one exists, and remembers a commit id - /// that is still represented in the graph. - /// - Virtual segments are empty, named graph nodes for real Git refs whose - /// commit is owned elsewhere, and connected according to their - /// GitButler-only role. - /// - Tip and ancestor segments with multiple parents own at least one commit. - /// - Traversal tips resolve to the first commit they describe. - /// - /// This always checks: - /// - /// - Edge weights still match the source and destination segment endpoints. - pub fn validation_errors(&self) -> Vec { - let mut out = Vec::new(); - if !self.hard_limit_hit { - out.extend(self.check_entrypoint_invariants().err()); - for segment_index in self.inner.node_indices() { - let segment = &self.inner[segment_index]; - let outgoing = self - .inner - .neighbors_directed(segment_index, Direction::Outgoing) - .count(); - out.extend( - [ - self.check_virtual_segments_are_empty_and_connected( - segment_index, - segment, - outgoing, - ), - self.check_multi_parent_tip_or_ancestor_segments_have_commits( - segment_index, - segment, - outgoing, - ), - ] - .into_iter() - .flat_map(Result::err), - ); - } - out.extend( - self.traversal_tips - .iter() - .filter_map(|tip| self.check_traversal_tip_points_to_first_commit(tip).err()), - ); - } - out.extend( - self.inner - .edge_references() - .filter_map(|edge| Self::check_edge(&self.inner, edge, false).err()), - ); - out - } - - /// The entrypoint is the user-facing traversal anchor. - /// - /// It must always point at an existing segment in completed graphs. If a - /// ref name was remembered for it, post-processing must have moved the - /// entrypoint to the segment with that name. - /// - /// If the entrypoint remembers a commit id, that id must either be the first - /// commit of its segment or be owned elsewhere as the first commit of another segment. - /// - /// The latter is valid for empty virtual workspace tip segments: they can fan out to - /// multiple stack segments and cannot resolve through a unique outgoing - /// edge, but the original traversal commit is still known. - fn check_entrypoint_invariants(&self) -> anyhow::Result<()> { - let (entrypoint_sidx, entrypoint_commit) = self - .entrypoint - .context("completed graph must have an entrypoint")?; - let segment = self - .inner - .node_weight(entrypoint_sidx) - .with_context(|| format!("entrypoint segment at {entrypoint_sidx:?} wasn't present"))?; - - if let Some(entrypoint_ref) = self.entrypoint_ref.as_ref() { - ensure!( - segment - .ref_name() - .is_some_and(|rn| rn == entrypoint_ref.as_ref()), - "{entrypoint_sidx:?}: entrypoint segment must be named {entrypoint_ref}, got {actual:?}", - actual = segment.ref_name() - ); - } - - match entrypoint_commit { - EntryPointCommit::Unborn => { - ensure!( - segment.commits.is_empty(), - "{entrypoint_sidx:?}: unborn entrypoint segment must not contain commits" - ); - } - EntryPointCommit::AtCommit(id) => { - if let Some(first_commit) = segment.commits.first() { - ensure!( - first_commit.id == id, - "{entrypoint_sidx:?}: entrypoint segment first commit is {actual}, not remembered entrypoint commit {id}", - actual = first_commit.id - ); - } else { - ensure!( - segment.ref_name().is_some(), - "{entrypoint_sidx:?}: empty entrypoint segment with remembered commit {id} must be named" - ); - let owner_segment = self.segment_by_commit_id(id).with_context(|| { - format!( - "{entrypoint_sidx:?}: empty entrypoint segment remembers {id}, but no segment owns that commit" - ) - })?; - ensure!( - owner_segment.commit_index_of(id) == Some(0), - "{entrypoint_sidx:?}: empty entrypoint segment remembers {id}, but owner {owner_segment_id:?} does not have it as first commit", - owner_segment_id = owner_segment.id - ); - } - } - } - Ok(()) - } - - /// Virtual segments are empty graph nodes that correspond to real Git - /// references whose commits are owned by another segment. - /// - /// Ordinary virtual segments can resolve to the commit named by their refs by - /// following their outgoing edge with - /// [`Self::resolve_to_unambiguously_pointed_to_commit()`]. Virtual workspace - /// tip segments are the exception: they can fan out to multiple stack tips - /// and are therefore ambiguous. Such segments are empty because the commit - /// they name is owned elsewhere, sometimes because another segment was - /// prioritized to own the commit when multiple refs point to it. They are - /// virtual because their relationships to other segments are not represented - /// by the Git commit-graph or references. To Git, these are refs pointing at - /// the same commit; GitButler sees one or more ordered stacks of branches. - fn check_virtual_segments_are_empty_and_connected( - &self, - segment_index: SegmentIndex, - segment: &Segment, - outgoing: usize, - ) -> anyhow::Result<()> { - if !segment.commits.is_empty() { - return Ok(()); - } - - let is_virtual_workspace_tip = segment.workspace_metadata().is_some(); - if is_virtual_workspace_tip { - ensure!( - segment.ref_name().is_some(), - "{segment_index:?}: virtual workspace tip segment must be named - we don't want empty anonymous segments" - ); - ensure!( - outgoing > 0, - "{segment_index:?}: virtual workspace tip segment must have at least one outgoing connection" - ); - } else if segment.ref_name().is_some() { - ensure!( - outgoing == 1, - "{segment_index:?}: virtual segment must have exactly one outgoing connection, got {outgoing}" - ); - } - Ok(()) - } - - /// Tip and ancestor segments with multiple parents must own a commit. - /// - /// Empty virtual workspace tip segments are excluded because they can fan - /// out from one real workspace ref to multiple stack tips. - fn check_multi_parent_tip_or_ancestor_segments_have_commits( - &self, - segment_index: SegmentIndex, - segment: &Segment, - outgoing: usize, - ) -> anyhow::Result<()> { - if segment.workspace_metadata().is_some() { - return Ok(()); - } - - ensure!( - outgoing <= 1 || !segment.commits.is_empty(), - "{segment_index:?}: tip or ancestor segment with {outgoing} outgoing connections must own at least one commit" - ); - Ok(()) - } - - /// A retained traversal tip must resolve to its first commit. - /// - /// If the segment named by the tip is empty, following its single outgoing - /// edge chain must reach a non-empty segment whose first commit is the tip - /// id. This validates the final-graph form of the initial tip-segment - /// invariant. - fn check_traversal_tip_points_to_first_commit( - &self, - tip: &crate::init::Tip, - ) -> anyhow::Result<()> { - if let Ok(segment_owning_tip_id) = self.segment_by_commit_id(tip.id) { - ensure!( - segment_owning_tip_id.commit_index_of(tip.id) == Some(0), - "{tip:?}: tip segment {segment_id:?} must contain the tip id as its first commit", - segment_id = segment_owning_tip_id.id - ); - return Ok(()); - } - - let Some(ref_name) = tip.ref_name.as_ref() else { - bail!( - "{tip:?}: tip id is not owned by any segment, but it must as no segment was found by commit-id" - ); - }; - let segment = self - .segment_by_ref_name(ref_name.as_ref()) - .with_context(|| format!("{tip:?}: tip ref {ref_name} is not owned by any segment"))?; - ensure!( - segment.commits.is_empty(), - "{tip:?}: named tip segment {segment_id:?} must be empty if it does not own the tip id", - segment_id = segment.id - ); - let (commit, owner_segment_index) = self - .resolve_to_unambiguously_pointed_to_commit(segment.id) - .with_context(|| { - format!( - "{tip:?}: empty tip segment {segment_id:?} must resolve through one outgoing path to a commit", - segment_id = segment.id - ) - })?; - ensure!( - commit.id == tip.id, - "{tip:?}: empty tip segment {segment_id:?} resolves to {actual}, not {expected}", - segment_id = segment.id, - actual = commit.id, - expected = tip.id - ); - // Extra-check - this invariant is also enforced by `resolve_to_unambiguously_pointed_to_commit()`. - ensure!( - self[owner_segment_index].commit_index_of(tip.id) == Some(0), - "{tip:?}: resolved tip owner {owner_segment_index:?} must contain the tip id as its first commit" - ); - Ok(()) - } - - /// Fail with an error if the `edge` isn't consistent. - pub(crate) 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!( - "{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!("{display:?}: edge must end on {first_index:?}"); - } - - let seg_cidx = src.commit_id_by_index(w.src); - if w.src_id != 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!( - "{display:?}: the desired destination index didn't match the one in the segment {seg_cidx:?}" - ); - } - Ok(()) - } -} - -impl Index for Graph { - type Output = Segment; - - fn index(&self, index: SegmentIndex) -> &Self::Output { - &self.inner[index] - } -} - -impl IndexMut for Graph { - fn index_mut(&mut self, index: SegmentIndex) -> &mut Self::Output { - &mut self.inner[index] - } -} - -impl Deref for Graph { - type Target = PetGraph; - - fn deref(&self) -> &Self::Target { - &self.inner - } -} - -// This in particular is only for those who know what they are doing. -impl std::ops::DerefMut for Graph { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.inner - } -} diff --git a/crates/but-graph/src/init/mod.rs b/crates/but-graph/src/init/mod.rs deleted file mode 100644 index 6a2bd750458..00000000000 --- a/crates/but-graph/src/init/mod.rs +++ /dev/null @@ -1,2239 +0,0 @@ -use std::collections::{BTreeMap, BTreeSet}; - -use anyhow::{Context as _, bail, ensure}; -use bstr::ByteSlice; -use but_core::{ - RefMetadata, extract_remote_name_and_short_name, - ref_metadata::{self, ProjectMeta}, -}; -use gix::{ - hashtable::hash_map::Entry, - prelude::{ObjectIdExt, ReferenceExt}, - refs::Category, -}; -use petgraph::Direction; -use tracing::instrument; - -use crate::{ - CommitFlags, CommitIndex, Edge, EntryPointCommit, Graph, Segment, SegmentIndex, SegmentMetadata, -}; - -mod walk; -use walk::*; - -pub(crate) mod types; -use types::{EdgeOwned, Goals, Instruction, Limit, Queue}; - -use crate::init::overlay::{OverlayMetadata, OverlayRepo}; - -mod remotes; - -mod overlay; -mod post; - -pub(crate) type Entrypoint = Option<(gix::ObjectId, Option)>; - -/// A resolved commit tip to seed graph traversal without requiring it to be -/// discoverable through repository refs or workspace metadata. -/// -/// ## Traversal invariants -/// -/// The traversal will build a segment graph, where Segments follow specific rules. -/// We differentiate between [tip segments](Segment), segments created from [Tip]s, (*TS*) and -/// ancestor segments (*AS*), which are ancestors of *TS* and connected to them by outgoing -/// connections. -/// -/// - Virtual segments (*VS*) are created in a post-processing step to represent refs -/// which are described in [but_core::ref_metadata::Workspace]. They are [named](Segment::ref_name()) -/// and always empty graph nodes, and ordinary virtual segments have *exactly one* -/// outgoing connection that lets [Graph::resolve_to_unambiguously_pointed_to_commit()] -/// find the commit named by the ref. The commit is owned by another segment, sometimes -/// because another segment was prioritized when multiple refs point to the same commit. -/// - The virtual workspace tip segment is a special kind of *VS*, which may have one or more -/// outgoing connections, pointing to one or more *VS* or *AS*. As such, such Segments cannot -/// unambiguously determine the commit their [Self::ref_name] points to as multiple paths can -/// be followed, yielding multiple commits. -/// Note that ordinary workspace tip segments may also exist as *TS*, which do own a commit, -/// which *typically* is the workspace commit. -/// - After the traversal, before post-processing, forks and joins of the underlying -/// commit graph are represented by segments. This allows traversals or -/// graph computations, like merge-bases, to work the same as on the commit-graph, but -/// possibly with less jumps among nodes as segments may contain more than one commit, -/// allowing to skip over uninteresting commits naturally. -/// - After post-processing, the graph may not fully represent the commit-graph anymore -/// due to the creation of *VS*. What makes a *VS* virtual is not the ref itself, -/// but that its relationship to other segments is not represented by the Git -/// commit-graph or by Git refs: to Git, these are refs pointing to the same commit, -/// while GitButler sees one or more stacks of branches with specific ordering. -/// - *TS* with [Self::ref_name] set will return that as [Segment::ref_name()]. -/// - *TS* that contain [Self::id] contain it as first commit -/// - *TS* that don't contain [Self::id] are empty and can find their commit by following -/// their only outgoing connection until a non-empty commit is found which contains -/// [Self::id] as *first* commit! -/// - *TS* or *AS* with *more than one* outgoing connection have *at least one* commit. -#[derive(Debug, Clone)] -pub struct Tip { - /// The commit id to start walking from. - pub id: gix::ObjectId, - /// The ref name to assign to the tip segment, if it should be named. - pub ref_name: Option, - /// How this tip participates in traversal. - pub role: TipRole, - /// Metadata to attach to the initial segment. - pub metadata: Option, - /// Whether this tip is the user-facing traversal entrypoint. - /// - /// There may only be *one such tip*. - /// Other tips try to connect to any commit reachable from this one. - pub is_entrypoint: bool, - /// Whether the entrypoint segment should remain anonymous even if refs - /// point at the same commit. - pub is_detached: bool, -} - -/// Lifecycle -impl Tip { - /// A traversal tip with default reachable semantics. - /// - /// This is the smallest tip description: it starts at `id`, is unnamed, is - /// not the entrypoint, carries no metadata, and queues after existing - /// initial work. - pub fn new(id: gix::ObjectId) -> Self { - Tip { - id, - ref_name: None, - role: TipRole::default(), - metadata: None, - is_entrypoint: false, - is_detached: false, - } - } - - /// A normal named or unnamed traversal entrypoint. - /// - /// `id` is the commit where graph traversal starts. - /// `ref_name` names the entrypoint segment when the caller has a stable ref - /// for it. - pub fn entrypoint(id: gix::ObjectId, ref_name: Option) -> Self { - Tip::new(id).with_ref_name(ref_name).with_entrypoint() - } - - /// An entrypoint whose segment should remain detached even if refs point to - /// its commit. - /// - /// `id` is the commit where graph traversal starts. - pub fn detached_entrypoint(id: gix::ObjectId) -> Self { - Tip::new(id).with_detached_entrypoint() - } - - /// A non-remote tip that should be included in the traversal. - /// - /// `id` is the commit to include as another non-remote traversal root. - /// `ref_name` names the tip segment when the caller has a stable ref for it. - pub fn reachable(id: gix::ObjectId, ref_name: Option) -> Self { - Tip::new(id).with_ref_name(ref_name) - } - - /// A target/integration tip that bounds or extends traversal context. - /// It represents part of the graph that [`Self::reachable()`] parts want to integrate with. - /// - /// `id` is the commit to treat as integrated history. - /// `ref_name` names the target segment when the caller has a stable ref for - /// it. - pub fn integrated(id: gix::ObjectId, ref_name: Option) -> Self { - Tip::new(id) - .with_ref_name(ref_name) - .with_role(TipRole::TargetRemote) - } -} - -/// Builder -impl Tip { - /// Set the ref name used to enforce the name this tip segment. - pub fn with_ref_name(mut self, ref_name: Option) -> Self { - self.ref_name = ref_name; - self - } - - /// Set the traversal role for this tip. - pub fn with_role(mut self, role: TipRole) -> Self { - self.role = role; - self - } - - /// Set whether this tip is the traversal entrypoint. - pub fn with_is_entrypoint(mut self, is_entrypoint: bool) -> Self { - self.is_entrypoint = is_entrypoint; - self - } - - /// Set whether this tip should use detached entrypoint presentation, which makes it anonymous even - /// if it could receive a name/unambiguous ref otherwise. - pub fn with_is_detached(mut self, is_detached: bool) -> Self { - self.is_detached = is_detached; - self - } - - /// Mark this tip as the traversal entrypoint. - pub fn with_entrypoint(self) -> Self { - self.with_is_entrypoint(true) - } - - /// Mark this entrypoint as detached for segment presentation. - pub fn with_detached_entrypoint(mut self) -> Self { - self = self.with_is_entrypoint(true).with_is_detached(true); - self - } - - /// Attach metadata to the initial segment created for this tip. - pub fn with_metadata(mut self, metadata: SegmentMetadata) -> Self { - self.metadata = Some(metadata); - self - } -} - -/// Utilities -impl Tip { - /// Return whether this tip is anonymous integrated target context. - /// - /// Named target remotes can represent refs that need their own segment and - /// target/local sibling relationship. Anonymous target remotes have no ref - /// to preserve in the projection; they represent commit-only target - /// context such as `extra_target_commit_id` or a persisted workspace target - /// commit. - fn is_anonymous_integrated_target_context(&self) -> bool { - matches!(self.role, TipRole::TargetRemote) && self.ref_name.is_none() - } - - /// Return whether this anonymous integrated target tip is auxiliary - /// traversal context. - /// - /// Anonymous target remotes can be provided explicitly by callers and - /// usually remain normal traversal seeds. The `auxiliary_integrated_tip_ids` - /// set records the anonymous integrated targets that normalization derived - /// from metadata or options such as `extra_target_commit_id`; those tips act - /// as mergeable limits/context and should be ordered or deduplicated as - /// auxiliary work rather than as user-visible roots. - /// - /// If an anonymous target points to the same commit as a named target ref, - /// normalization collapses it into the named tip. - fn is_auxiliary_integrated_tip( - &self, - auxiliary_integrated_tip_ids: &BTreeSet, - ) -> bool { - self.is_anonymous_integrated_target_context() - && auxiliary_integrated_tip_ids.contains(&self.id) - } - - /// Return whether this anonymous integrated target should reuse the named - /// target traversal seed for the same commit. - /// - /// The anonymous tip only contributes commit-level target context - /// (tips with [TipRole::TargetRemote]). It does not need its own segment or - /// queue item when a named target ref already points at that commit, - /// and keeping both can make the anonymous seed own the commit while - /// the named ref is left as a duplicate empty segment. - fn collapses_into_named_integrated_target( - &self, - named_integrated_target_ids: &BTreeSet, - ) -> bool { - self.is_anonymous_integrated_target_context() - && named_integrated_target_ids.contains(&self.id) - } -} - -/// The role a resolved traversal tip plays when constructing a graph. -/// -/// Roles decide the initial [`CommitFlags`] and `Limit` goals used by the -/// walk. The explicit entrypoint is the shared goal: reachable and integrated -/// tips seek connection to it by walking history until they encounter the entrypoint's -/// propagated goal flag. -/// -/// Remote-tracking tips are not modeled as explicit [`TipRole`] values. They -/// are discovered during traversal from refs found at visited commits and their -/// configured or deduced remote-tracking branches. When such a remote tip is -/// queued, it receives an indirect goal for the local commit where it was -/// discovered, while that local side receives a goal for the remote tip. This -/// reciprocal goal setup lets remote and local tracking histories converge until -/// the graph can connect them. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub enum TipRole { - /// A non-remote tip that should be traversed and related to the entrypoint. - /// - /// This tip marks all commits it traverses with [`CommitFlags::NotInRemote`]. - #[default] - Reachable, - /// The workspace ref itself, paired with workspace metadata on [`Tip`]. - /// - /// This marks commits as in-workspace with [`CommitFlags::InWorkspace`]. - Workspace, - /// A branch from a stack listed in workspace metadata. - /// - /// Its current ref tip should be traversed even if it is not reachable from - /// the workspace commit. - WorkspaceStackBranch { - /// Ref name from workspace metadata to use for segment naming if the - /// initial segment cannot infer an unambiguous ref from the tip commit. - /// - /// This is not [`Tip::ref_name`] because that field forces the initial - /// segment to use the supplied name. Workspace stack branches should - /// still allow normal ref discovery to pick an unambiguous local branch - /// at the tip commit, or to leave the segment anonymous when local - /// naming is ambiguous. The desired name is only a fallback for - /// remote-only stack refs that cannot be discovered by local-branch - /// disambiguation. - /// - /// Note that [Tip::id] is assumed to be the peeled commit that this - /// ref points to. - desired_ref_name: gix::refs::FullName, - }, - /// A target/integration tip whose reachable history is considered integrated, - /// and that reachable/unintegrated tips want to connect with. - /// - /// This tip receives [`CommitFlags::Integrated`] and an indirect goal for - /// the entrypoint commit with no extra allowance once that goal is found. It - /// walks just far enough to connect target history to the entrypoint's - /// ancestry. - TargetRemote, - /// The local branch that tracks an integrated target branch. - /// - /// It receives a goal for the target and later provides the segment id that - /// lets the target segment point back to its local sibling. - TargetLocal { - /// The expected local tracking ref name used to verify whether the - /// segment that normal ref discovery created is actually the local side - /// of this target. - /// - /// This is not [`Tip::ref_name`] because that would force the segment - /// to use this name and bypass ambiguity checks. If multiple local - /// branches point to the same commit, or discovery chooses a different - /// unambiguous name, the target should still get the local goal but not - /// a direct sibling link. - /// - /// This matters when the target's local tracking branch shares its tip - /// with another local branch, such as a workspace stack branch or a - /// second branch with metadata. In that state, the segment may - /// represent that other branch or stay anonymous; linking it as the - /// target local side would make target ahead/behind and remote-reachability - /// queries treat the wrong segment as the tracking branch. - local_ref_name: gix::refs::FullName, - }, -} - -/// Access -impl TipRole { - /// Whether this role represents integrated history. - pub fn is_integrated(&self) -> bool { - matches!(self, TipRole::TargetRemote) - } -} - -/// A local branch ref and the commit it points to, when it tracks a workspace -/// target ref. -type LocalTrackingTip = (gix::refs::FullName, gix::ObjectId); - -/// A workspace target ref, its commit, and optionally the local branch tracking it. -type WorkspaceTargetTip = (gix::refs::FullName, gix::ObjectId, Option); - -/// The complete pre-traversal plan derived from either explicit tips or -/// workspace metadata. -/// -/// [`queue_initial_tips()`] consumes this value to create graph *segments*, seed -/// the traversal queue, and provide the auxiliary ref and remote information -/// needed by traversal and post-processing. -/// -/// This means that each of these tip *will get its own possibly empty* graph segment. -struct InitialTips { - /// Ordered traversal roots to turn into segments and queue items. - tips: Vec, - /// Workspace commits used to ensure commits remain owned by the workspace - /// roots that introduced them. - workspace_tips: Vec, - /// Workspace ref names that should be included while collecting refs by - /// prefix, even when they are not reachable from the entrypoint yet. - workspace_ref_names: Vec, - /// Remote target refs that were already scheduled as initial integrated - /// tips. - /// - /// Workspace traversals seed this list from the project metadata target - /// ref. Explicit traversal seeds the same list from integrated tip ref - /// names. During traversal, `try_queue_remote_tracking_branches()` uses - /// it to avoid queueing those target refs again when local branch refs - /// point at them as upstreams. - // TODO: could this be removed in favor os using `Graph::traversal_tips`? - target_refs: Vec, - /// Remote names to try when a local branch has no configured upstream. - /// - /// `lookup_remote_tracking_branch_or_deduce_it()` first asks Git for the - /// branch's configured remote-tracking ref. If none exists, it tries each - /// name here by constructing `refs/remotes//` and - /// using it only if that ref exists and is not already configured for - /// another branch. - symbolic_remote_names: Vec, - /// Whether metadata-derived workspace/target tips should be front-loaded - /// into the traversal queue after their segments are created. - frontload_workspace_related_tips: bool, - /// Target remote/local tracking relationships inferred from tip refs and - /// repository branch configuration. - /// - /// These links are needed before traversal starts because target and local - /// tracking tips may point to the same commit, or may be reached in either - /// order. Queueing uses this map to delay the target side until the local - /// side has a segment and goal, then links both segments as siblings before - /// their commits can be claimed by unrelated stack or reachable tips. That - /// keeps target ownership, ahead/behind, and remote-reachability queries - /// anchored to the intended target/local pair. - target_local_links: TargetLocalLinks, - /// Anonymous target-remote tips that are auxiliary traversal context rather - /// than primary target refs. - auxiliary_integrated_tip_ids: BTreeSet, -} - -/// Bidirectional lookup between target remote refs and their local tracking refs. -#[derive(Default)] -struct TargetLocalLinks { - /// Local tracking ref by target remote ref. - local_by_target: BTreeMap, - /// Target remote ref by local tracking ref. - target_by_local: BTreeMap, -} - -/// An integrated target that has a segment but cannot be queued yet. -/// -/// This temporary state is needed when the target should be linked to a local -/// tracking branch that appears later in the normalized initial-tip list. Once -/// the local side exists, the pending target can be queued with the correct -/// sibling relationship and goal. -struct PendingIntegratedTip { - /// Commit id of the integrated target. - id: gix::ObjectId, - /// Segment created for the integrated target before it is queued. - segment: SegmentIndex, - /// Whether to insert the target before existing initial queue work once it - /// is released. - queue_front: bool, -} - -/// A way to define information to be served from memory, instead of from the underlying data source, when -/// [initializing](Graph::from_commit_traversal()) the graph. -#[derive(Debug, Default, Clone)] -pub struct Overlay { - entrypoint: Entrypoint, - nonoverriding_references: Vec, - overriding_references: Vec, - /// A list of references that should not be picked up anymore in the - /// re-traversal. - /// - /// For example, if the `but_rebase::graph_rebase::Editor` converts a - /// `Reference` step to a `None` step which is the equivalent of running - /// `git update-ref -d`, it should no longer be part of the [`Graph`], so we - /// would list the particular reference as a dropped reference. - dropped_references: Vec, - meta_branches: Vec<(gix::refs::FullName, ref_metadata::Branch)>, - branch_stack_orders: Vec>, - workspace: Option<(gix::refs::FullName, ref_metadata::Workspace)>, -} - -pub(super) type PetGraph = petgraph::stable_graph::StableGraph; - -/// Options for use in [`Graph::from_head()`] and [`Graph::from_commit_traversal()`]. -#[derive(Default, Debug, Clone)] -pub struct Options { - /// Associate tag references with commits. - /// - /// If `false`, tags are not collected. - pub collect_tags: bool, - /// 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 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 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 branch independently - /// of the limit. - 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`. - /// 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 queuing new commits after pretty much this many - /// commits have been seen. - /// - /// This is a last line of defense against runaway traversals and for now 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. Commits that are already queued are still processed so - /// their existing graph connections can be completed. - /// - /// 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, - /// Provide the commit that should act like the tip of an additional target reference, - /// just as if it was set by one of the workspaces. - /// Everything it touches will be considered integrated, and it can be used - /// to extend the border of the workspace. Typically, it's a past position - /// of an existing target, or a target chosen by the user. - pub extra_target_commit_id: Option, - /// Enabling this will prevent the postprocessing step to run which is what makes the graph useful through clean-up - /// and to make it more amenable to a workspace project. - /// - /// This should only be used in case post-processing fails and one wants to preview the version before that. - pub dangerously_skip_postprocessing_for_debugging: bool, -} - -/// Presets -impl Options { - /// Return options that won't traverse the whole graph if there is no workspace, but will show - /// more than enough commits by default. - pub fn limited() -> Self { - Options { - collect_tags: false, - commits_limit_hint: Some(300), - ..Default::default() - } - } -} - -/// Builder -impl Options { - /// 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 - } - - /// 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. - /// - /// ### Warning - /// - /// This stops traversal early despite not having discovered all desired graph partitions, possibly leading to - /// incorrect results. Ideally, this is not used. - 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, - ) -> Self { - self.commits_limit_recharge_location.extend(commits); - self - } - - /// Set an additional integrated traversal tip. - /// It's most useful for tests which want to affect the target of the workspace - /// without the respective setup. - /// Application code may use it to set global targets, to reduce the amount of - /// commits in the workspace even if the entrypoint otherwise is the target branch. - /// - /// The commit is queued like an integrated target so traversal can connect - /// the workspace to history that may otherwise be outside the ordinary - /// target ref or workspace metadata. The tip is also kept as a tip of - /// interest and re-resolved after post-processing so workspace projection - /// can use it as a past target/base candidate. - pub fn with_extra_target_commit_id(mut self, id: impl Into) -> Self { - self.extra_target_commit_id = Some(id.into()); - self - } -} - -/// Lifecycle -impl Graph { - /// Read the `HEAD` of `repo` and represent whatever is visible as a graph. - /// - /// See [`Self::from_commit_traversal()`] for details. - pub fn from_head( - repo: &gix::Repository, - meta: &impl RefMetadata, - project_meta: ProjectMeta, - options: Options, - ) -> anyhow::Result { - let head = repo.head()?; - let mut is_detached = false; - let (tip, maybe_name) = match head.kind { - gix::head::Kind::Unborn(ref_name) => { - let mut graph = Graph { - project_meta, - ..Default::default() - }; - // It's OK to default-initialise this here as overlays are only used when redoing - // the traversal. - let (_repo, meta, _entrypoint) = Overlay::default().into_parts(repo, meta); - let wt_by_branch = { - // Assume linked worktrees are never unborn! - let mut m = BTreeMap::new(); - m.insert( - ref_name.clone(), - vec![crate::Worktree { - kind: crate::WorktreeKind::Main, - owned_by_repo: true, - }], - ); - m - }; - graph.insert_segment_set_entrypoint(branch_segment_from_name_and_meta( - Some((ref_name, None)), - &meta, - None, - &wt_by_branch, - )?); - return Ok(graph); - } - gix::head::Kind::Detached { target, peeled } => { - is_detached = true; - (peeled.unwrap_or(target).attach(repo), None) - } - gix::head::Kind::Symbolic(existing_reference) => { - let mut existing_reference = existing_reference.attach(repo); - let tip = existing_reference.peel_to_id()?; - (tip, Some(existing_reference.inner.name)) - } - }; - - let mut graph = Self::from_commit_traversal(tip, maybe_name, meta, project_meta, options)?; - if is_detached { - graph.detach_entrypoint_segment()?; - } - Ok(graph) - } - /// Produce a minimal but usable representation of the commit-graph reachable from the commit at `tip` such the returned instance - /// can represent everything that's observed, without losing information. - /// `ref_name` is assumed to point to `tip` if given. - /// - /// `meta` is used to learn more about the encountered references, and `options` is used for additional configuration. - /// - /// ### Features - /// - /// * discover a Workspace on the fly based on `meta`-data. - /// * support the notion of a branch to integrate with, the *target* - /// - *target* branches consist of a local and remote tracking branch, and one can be ahead of the other. - /// - workspaces are relative to the local tracking branch of the target. - /// - options contain an [`extra_target_commit_id`](Options::extra_target_commit_id) for an additional target location. - /// * remote tracking branches are seen in relation to their branches. - /// * the graph of segments assigns each reachable commit to exactly one segment - /// * one can use [`petgraph::algo`] and [`petgraph::visit`] - /// - It maintains information about the intended connections, so modifications afterward will show - /// in debugging output if edges are now in violation of this constraint. - /// - /// ### Rules/Invariants - /// - /// 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. - /// - /// * Traversal is seeded from [`Tip`]s. Workspace metadata traversal first - /// resolves metadata into tips, then follows the same path as callers - /// passing explicit tips. - /// * Explicit tips must contain exactly one entrypoint, must not contain - /// duplicate traversal seeds, and any named tip must have a ref that - /// resolves to its commit id. A traversal seed is the commit id, the - /// traversal role, and whether that tip is the entrypoint; naming, - /// metadata, detached presentation, and queue position do not make it - /// useful to enqueue the same seed twice. - /// * Multiple tips with different [roles](TipRole) may point to the same commit id, - /// as multiple refs can name the same commit. - /// * A detached tip must be the entrypoint and cannot carry a ref name. - /// * The entrypoint always causes the start of a [`Segment`]. - /// * Tips discovered from workspace metadata preserve their queue order. - /// Explicit tips without a custom queue position are normalized into - /// deterministic traversal order: integrated and target tips first, - /// reachable/workspace tips next, and the entrypoint last. - /// * A commit can be governed by multiple workspaces. - /// * As workspaces and entrypoints "grow" together, we don't know anything - /// about workspaces until the very end, or when two partitions of commits - /// touch. This means we can't make decisions based on - /// [flags](CommitFlags) until the traversal is finished. - /// * Segments are named if their first commit has a single local branch - /// pointing to it, or a branch that otherwise can be disambiguated. - /// * Anonymous segments are created if their name is ambiguous. - /// * Anonymous segments are created if another segment connects to a commit - /// that it contains that is not the first one. - /// - This means, all connections go *from the last commit in a segment to the first commit in another segment*. - /// * Stacks and branches stored in the *workspace metadata* are relevant only if they - /// become tips backed by an existing branch. - /// * Remote tracking branches are picked up during traversal for any ref - /// that we reached through traversal. - /// - Remote tracking branches are discovered only for refs encountered - /// during traversal. Segments created later during post-processing, - /// especially virtual or empty segments, do not cause additional remote - /// traversal. - /// - Remote tracking branches never take commits that are already owned. - /// * The traversal is cut short when only integrated tips remain. - /// * 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(name = "Graph::from_commit_traversal", level = "trace", skip_all, fields(tip = ?tip, ref_name), err(Debug))] - pub fn from_commit_traversal( - tip: gix::Id<'_>, - ref_name: impl Into>, - meta: &impl RefMetadata, - project_meta: ProjectMeta, - options: Options, - ) -> anyhow::Result { - let repo = tip.repo; - let tip = tip.detach(); - let (overlay_repo, overlay_meta, _entrypoint) = Overlay::default().into_parts(repo, meta); - let ref_name = ref_name.into(); - let tips = initial_tips_from_workspace_metadata( - &overlay_repo, - &overlay_meta, - tip, - ref_name.as_ref(), - &project_meta, - options.extra_target_commit_id, - )?; - Graph::traverse_tips_with_overlay( - &overlay_repo, - tips, - &overlay_meta, - project_meta, - options, - ref_name, - ) - } - - /// Produce a graph from already resolved tips and their traversal roles. - /// - /// This is useful for callers that already know the commits they want to - /// relate, or whose tips are not represented by durable repository refs or - /// workspace metadata. - /// - /// `repo` provides commit objects, refs, remotes, worktrees, and optional - /// commit-graph acceleration for traversal. - /// `tips` provides the resolved commits and their traversal roles. It must - /// contain exactly one tip whose [`Tip::is_entrypoint`] flag is set. - /// `meta` provides branch metadata for any refs encountered while walking. - /// `options` controls tag collection, traversal limits, additional - /// integrated tips, and post-processing behavior. - pub fn from_commit_traversal_tips( - repo: &gix::Repository, - tips: impl IntoIterator, - meta: &impl RefMetadata, - project_meta: ProjectMeta, - options: Options, - ) -> anyhow::Result { - let tips: Vec<_> = tips.into_iter().collect(); - let (overlay_repo, overlay_meta, _entrypoint) = Overlay::default().into_parts(repo, meta); - Graph::traverse_tips_with_overlay( - &overlay_repo, - tips, - &overlay_meta, - project_meta, - options, - None, - ) - } - - fn traverse_tips_with_overlay( - repo: &OverlayRepo<'_>, - tips: Vec, - meta: &OverlayMetadata<'_, T>, - project_meta: ProjectMeta, - options: Options, - entrypoint_ref_override: Option, - ) -> anyhow::Result { - let entrypoint = validate_explicit_tips(repo, &tips, entrypoint_ref_override.as_ref())?; - let tip = entrypoint.id; - let ref_name = if entrypoint.is_detached { - None - } else { - entrypoint_ref_override.or_else(|| entrypoint.ref_name.clone()) - }; - let detach_entrypoint = entrypoint.is_detached; - - { - if let Some(name) = &ref_name { - let span = tracing::Span::current(); - span.record("ref_name", name.as_bstr().to_str_lossy().as_ref()); - } - } - let mut graph = Graph { - options: options.clone(), - entrypoint_ref: ref_name.clone(), - project_meta, - ..Graph::default() - }; - let Options { - collect_tags, - extra_target_commit_id, - commits_limit_hint: limit, - commits_limit_recharge_location: mut max_commits_recharge_location, - hard_limit, - dangerously_skip_postprocessing_for_debugging, - } = options; - let max_limit = Limit::new(limit); - if ref_name - .as_ref() - .is_some_and(|name| name.category() == Some(Category::RemoteBranch)) - { - // TODO: see if this is a thing - Git doesn't like to checkout remote tracking branches by name, - // and if we should handle it, we need to setup the initial flags accordingly. - // Also we have to assure not to double-traverse the ref, once as tip and once by discovery. - bail!("Cannot currently handle remotes as start position"); - } - let commit_graph = repo.commit_graph_if_enabled()?; - let shallow_commits = repo.shallow_commits()?; - let mut buf = Vec::new(); - - let configured_remote_tracking_branches = - remotes::configured_remote_tracking_branches(repo)?; - let initial_tips = - initial_tips_from_tips(repo, tips, &graph.project_meta, extra_target_commit_id); - graph.traversal_tips = initial_tips.tips.clone(); - let refs_by_id = repo.collect_ref_mapping_by_prefix( - [ - "refs/heads/", - // Remote refs are special as we collect them into commits to know about them, - // just to later remove them unless they are on an actual remote commit. - // In that case, we also split the segment there if the previous segment then wouldn't be empty. - // Naturally we only pick them up and segment them if they are added by the local tracking branch - // that was seen in the walk before. - "refs/remotes/", - ] - .into_iter() - .chain(if collect_tags { - Some("refs/tags/") - } else { - None - }), - &initial_tips - .workspace_ref_names - .iter() - .map(|ref_name| ref_name.as_ref()) - .collect::>(), - )?; - let mut seen = gix::revwalk::graph::IdMap::::default(); - let mut goals = Goals::default(); - // The tip transports itself. - let tip_flags = CommitFlags::NotInRemote - | goals - .flag_for(tip) - .expect("we more than one bitflags for this"); - - let mut next = Queue::new_with_limit(hard_limit); - let worktree_by_branch = - repo.worktree_branches(graph.entrypoint_ref.as_ref().map(|r| r.as_ref()))?; - - let mut ctx = post::Context { - repo, - symbolic_remote_names: &initial_tips.symbolic_remote_names, - configured_remote_tracking_branches: &configured_remote_tracking_branches, - inserted_proxy_segments: Vec::new(), - refs_by_id, - hard_limit: false, - detach_entrypoint, - dangerously_skip_postprocessing_for_debugging, - worktree_by_branch, - }; - - let target_limit = max_limit - .with_indirect_goal(tip, &mut goals) - .without_allowance(); - ctx.inserted_proxy_segments = queue_initial_tips( - &mut graph, - &mut next, - &initial_tips, - tip, - tip_flags, - max_limit, - target_limit, - &mut goals, - commit_graph.as_ref(), - repo, - meta, - &ctx, - &mut buf, - )?; - max_commits_recharge_location.sort(); - let mut points_of_interest_to_traverse_first = next.iter().count(); - while let Some((info, mut propagated_flags, instruction, mut limit)) = next.pop_front() { - points_of_interest_to_traverse_first = - points_of_interest_to_traverse_first.saturating_sub(1); - - let id = info.id; - if max_commits_recharge_location.binary_search(&id).is_ok() { - limit.set_but_keep_goal(max_limit); - } - let src_flags = graph[instruction.segment_idx()] - .commits - .last() - .map(|c| c.flags) - .unwrap_or_default(); - - // These flags might be outdated as they have been queued, meanwhile we may have propagated flags. - // So be sure this gets picked up. - propagated_flags |= src_flags; - let is_shallow_boundary = shallow_commits - .as_ref() - .is_some_and(|boundary| boundary.binary_search(&id).is_ok()); - if is_shallow_boundary { - propagated_flags |= CommitFlags::ShallowBoundary; - } - let segment_idx_for_id = match instruction { - Instruction::CollectCommit { into: src_sidx } => match seen.entry(id) { - Entry::Occupied(_) => { - possibly_split_occupied_segment( - &mut graph, - &mut seen, - &mut next, - id, - propagated_flags, - src_sidx, - limit, - 0, - )?; - continue; - } - Entry::Vacant(e) => { - let src_sidx = try_split_non_empty_segment_at_branch( - &mut graph, - src_sidx, - &info, - &ctx.refs_by_id, - meta, - &ctx.worktree_by_branch, - )? - .unwrap_or(src_sidx); - e.insert(src_sidx); - src_sidx - } - }, - Instruction::ConnectNewSegment { - parent_above, - at_commit, - parent_order, - } => match seen.entry(id) { - Entry::Occupied(_) => { - possibly_split_occupied_segment( - &mut graph, - &mut seen, - &mut next, - id, - propagated_flags, - parent_above, - limit, - parent_order, - )?; - continue; - } - Entry::Vacant(e) => { - let segment_below = branch_segment_from_name_and_meta( - None, - meta, - Some((&ctx.refs_by_id, id)), - &ctx.worktree_by_branch, - )?; - let segment_below = graph.connect_new_segment( - parent_above, - at_commit as CommitIndex, - segment_below, - 0, - id, - parent_order, - ); - e.insert(segment_below); - segment_below - } - }, - }; - - let refs_at_commit_before_removal = ctx.refs_by_id.remove(&id).unwrap_or_default(); - let RemoteQueueOutcome { - items_to_queue_later: remote_items_to_queue_later, - maybe_make_id_a_goal_so_remote_can_find_local, - limit_to_let_local_find_remote, - } = try_queue_remote_tracking_branches( - repo, - &refs_at_commit_before_removal, - &mut graph, - &initial_tips.symbolic_remote_names, - &configured_remote_tracking_branches, - &initial_tips.target_refs, - meta, - id, - limit, - &mut goals, - &next, - &ctx.worktree_by_branch, - commit_graph.as_ref(), - repo.for_find_only(), - &mut buf, - )?; - - let segment = &mut graph[segment_idx_for_id]; - let commit_idx_for_possible_fork = segment.commits.len(); - let propagated_flags = propagated_flags | maybe_make_id_a_goal_so_remote_can_find_local; - queue_parents( - &mut next, - &info.parent_ids, - propagated_flags, - segment_idx_for_id, - commit_idx_for_possible_fork, - limit.additional_goal(limit_to_let_local_find_remote), - is_shallow_boundary, - commit_graph.as_ref(), - repo.for_find_only(), - &mut buf, - )?; - - segment.commits.push( - info.into_commit( - segment - .commits - // Flags are additive, and meanwhile something may have dumped flags on us - // so there is more compared to when the 'flags' value was put onto the queue. - .last() - .map_or(propagated_flags, |last| last.flags | propagated_flags), - refs_at_commit_before_removal - .clone() - .into_iter() - .filter(|rn| segment.ref_name() != Some(rn.as_ref())) - .collect(), - &ctx.worktree_by_branch, - )?, - ); - - for item in remote_items_to_queue_later { - if next.push_back_exhausted(item) { - // The break here means we may end up with unconnected remote tracking ref segments, - // that's fine. If it ever is not, we should remove the hard limit. - break; - } - } - - prune_integrated_tips(&mut graph, &mut next)?; - if points_of_interest_to_traverse_first == 0 { - next.sort(); - } - } - - ctx.hard_limit = next.hard_limit_hit(); - graph.post_processed(meta, tip, ctx) - } - - /// Take the ref-info from a named segment and put it back onto the first commit - /// where it pointed to before it was lifted up. - /// - /// Graph traversal eagerly names segments from refs pointing at their - /// first commit. Detached entrypoints keep those refs on the commit, but - /// the entrypoint segment itself must stay anonymous. - fn detach_entrypoint_segment(&mut self) -> anyhow::Result<()> { - let sidx = self - .entrypoint - .context("BUG: entrypoint is set after first traversal")? - .0; - let s = &mut self[sidx]; - if let Some((rn, first_commit)) = s - .commits - .first_mut() - .and_then(|first_commit| s.ref_info.take().map(|rn| (rn, first_commit))) - { - first_commit.refs.push(rn); - } - Ok(()) - } - - /// Repeat the traversal that generated this graph using `repo` and `meta`, but allow to set an in-memory - /// `overlay` to amend the data available from `repo` and `meta`. - /// This way, one can see this graph as it will be in the future once the changes to `repo` and `meta` are actually made. - pub fn redo_traversal_with_overlay( - &self, - repo: &gix::Repository, - meta: &impl RefMetadata, - overlay: Overlay, - ) -> anyhow::Result { - let (repo, meta, entrypoint) = overlay.into_parts(repo, meta); - let (tip, ref_name) = match entrypoint { - Some(t) => t, - None => { - let (entrypoint_sidx, commit) = self - .entrypoint - .context("BUG: entrypoint must always be set")?; - let entrypoint_segment = self - .inner - .node_weight(entrypoint_sidx) - .context("BUG: entrypoint segment must be present")?; - let mut ref_name = entrypoint_segment.ref_info.clone().map(|ri| ri.ref_name); - let tip = if let Some(name) = ref_name.as_ref() { - match repo.try_find_reference(name.as_ref())? { - Some(mut reference) => Some(reference.peel_to_id()?.detach()), - None => { - // The previous traversal may have had a named entrypoint, but - // this overlay can drop that ref. If so, don't carry a stale - // entrypoint_ref override into the new traversal; it would fail - // validation instead of re-traversing from the remembered commit. - ref_name = None; - None - } - } - } else { - None - }; - let tip = tip - .or_else(|| commit.object_id()) - .context( - "BUG: entrypoint must either remember the original commit id or have a resolvable ref", - )?; - (tip, ref_name) - } - }; - let tips = initial_tips_from_workspace_metadata( - &repo, - &meta, - tip, - ref_name.as_ref(), - &self.project_meta, - self.options.extra_target_commit_id, - )?; - Graph::traverse_tips_with_overlay( - &repo, - tips, - &meta, - self.project_meta.clone(), - self.options.clone(), - ref_name, - ) - } - - /// Like [`Self::redo_traversal_with_overlay()`], but replaces this instance, without overlay, and returns - /// a newly computed workspace for it. - pub fn into_workspace_of_redone_traversal( - mut self, - repo: &gix::Repository, - meta: &impl RefMetadata, - ) -> anyhow::Result { - let new = self.redo_traversal_with_overlay(repo, meta, Default::default())?; - self = new; - self.into_workspace() - } -} - -/// Validate caller-provided traversal tips before they seed graph traversal. -/// -/// Explicit tips must name exactly one entrypoint, must not contain duplicate -/// traversal seeds or repeated ref names, must keep detached entrypoints -/// unnamed, and any supplied ref name must resolve to the same commit id as its -/// tip. -fn validate_explicit_tips<'a>( - repo: &OverlayRepo<'_>, - tips: &'a [Tip], - entrypoint_ref_override: Option<&gix::refs::FullName>, -) -> anyhow::Result<&'a Tip> { - let mut entrypoints = tips.iter().filter(|tip| tip.is_entrypoint); - let entrypoint = entrypoints - .next() - .context("explicit traversal tips require exactly one entrypoint")?; - ensure!( - entrypoints.next().is_none(), - "explicit traversal tips require exactly one entrypoint" - ); - - for (idx, tip) in tips.iter().enumerate() { - ensure!( - !tip.is_detached || tip.is_entrypoint, - "explicit detached tip must also be the entrypoint" - ); - ensure!( - !tip.is_detached || tip.ref_name.is_none(), - "explicit detached entrypoint tip cannot have a ref name" - ); - ensure!( - !tip.is_entrypoint || matches!(tip.role, TipRole::Reachable | TipRole::Workspace), - "explicit entrypoint tip must be reachable or workspace" - ); - - for previous in &tips[..idx] { - ensure!( - !tips_have_same_traversal_seed(previous, tip), - "explicit traversal tips contain duplicate traversal seed {tip:?}" - ); - if let Some(ref_name) = tip - .ref_name - .as_ref() - .filter(|ref_name| previous.ref_name.as_ref() == Some(*ref_name)) - { - bail!("explicit traversal tips contain duplicate ref name {ref_name}"); - } - } - - if let Some(ref_name) = tip.ref_name.as_ref() { - validate_tip_ref(repo, ref_name, tip.id, "explicit traversal tip ref")?; - } - } - - if !entrypoint.is_detached - && let Some(ref_name) = entrypoint_ref_override - { - validate_tip_ref( - repo, - ref_name, - entrypoint.id, - "explicit traversal entrypoint ref", - )?; - } - - Ok(entrypoint) -} - -fn validate_tip_ref( - repo: &OverlayRepo<'_>, - ref_name: &gix::refs::FullName, - tip_id: gix::ObjectId, - context: &str, -) -> anyhow::Result<()> { - let resolved_id = repo - .try_find_reference(ref_name.as_ref())? - .with_context(|| format!("{context} {ref_name} does not exist"))? - .peel_to_id()? - .detach(); - ensure!( - resolved_id == tip_id, - "{context} {ref_name} points to {resolved_id}, not {tip_id}" - ); - Ok(()) -} - -/// Return whether two tips would seed the same traversal work. -/// -/// The traversal seed is the commit id, the traversal role, and whether the tip -/// is the entrypoint. Labels and presentation data like `ref_name`, metadata, -/// detached entrypoint mode, and caller order are intentionally ignored here: -/// they can affect naming, post-processing, or stable tie-breaking, but they -/// don't make it useful to enqueue the same commit with the same traversal -/// semantics twice. -fn tips_have_same_traversal_seed(previous: &Tip, tip: &Tip) -> bool { - previous.id == tip.id - && tips_have_same_seed_role(previous, tip) - && previous.is_entrypoint == tip.is_entrypoint -} - -/// Return whether two tips have the same traversal role for deduplication. -/// -/// [`TipRole::TargetRemote`] is special because named and anonymous target -/// remotes with the same commit can have different responsibilities. A named -/// target remote represents a ref that may need its own segment, -/// metadata-derived target identity, and target/local sibling link. An -/// anonymous target remote represents commit-only target context, such as -/// `extra_target_commit_id` or a persisted target commit. Validation accepts -/// those two forms so callers can pass metadata-equivalent tips directly; -/// normalization later collapses the anonymous form into the named tip if they -/// point to the same commit. -fn tips_have_same_seed_role(previous: &Tip, tip: &Tip) -> bool { - match (&previous.role, &tip.role) { - (TipRole::TargetRemote, TipRole::TargetRemote) => { - previous.ref_name.is_some() == tip.ref_name.is_some() - } - _ => previous.role == tip.role, - } -} - -/// Build auxiliary traversal inputs from normalized tips. -fn initial_tips_from_tips( - repo: &OverlayRepo<'_>, - mut tips: Vec, - project_meta: &ProjectMeta, - extra_target_commit_id: Option, -) -> InitialTips { - let mut auxiliary_integrated_tip_ids = BTreeSet::new(); - if let Some(extra_target) = extra_target_commit_id { - auxiliary_integrated_tip_ids.insert(extra_target); - push_integrated_tip_once(&mut tips, extra_target); - } - let frontload_workspace_related_tips = has_workspace_related_tips(&tips); - if frontload_workspace_related_tips { - auxiliary_integrated_tip_ids.extend(tips.iter().filter_map(|tip| { - tip.is_anonymous_integrated_target_context() - .then_some(tip.id) - })); - } - collapse_anonymous_integrated_tips_into_named_targets(&mut tips); - let tips = tips_in_queue_order(tips, &auxiliary_integrated_tip_ids); - let workspace_tips = tips - .iter() - .filter(|tip| matches!(tip.role, TipRole::Workspace)) - .map(|tip| tip.id) - .collect(); - let workspace_ref_names = tips - .iter() - .filter(|tip| matches!(tip.role, TipRole::Workspace)) - .filter_map(|tip| tip.ref_name.clone()) - .collect(); - let include_tip_refs = !tips - .iter() - .any(|tip| matches!(tip.metadata, Some(SegmentMetadata::Workspace(_)))); - let target_refs = target_refs_from_tips(&tips, project_meta, include_tip_refs); - let symbolic_remote_names = - symbolic_remote_names_from_tips(repo, &tips, project_meta, include_tip_refs); - let target_local_links = target_local_links_from_tips(repo, &tips); - - InitialTips { - tips, - workspace_tips, - workspace_ref_names, - target_refs, - symbolic_remote_names, - frontload_workspace_related_tips, - target_local_links, - auxiliary_integrated_tip_ids, - } -} - -/// Remove anonymous integrated target tips that point to the same commit as a -/// named integrated target. -/// -/// Workspace projection derives target context from target-remote tips by graph -/// position, so a same-commit anonymous target does not contribute anything -/// once a named target ref covers that commit. Collapsing here keeps one -/// effective traversal seed and lets the named target segment own the commit. -fn collapse_anonymous_integrated_tips_into_named_targets(tips: &mut Vec) { - let named_integrated_target_ids = tips - .iter() - .filter_map(|tip| { - (matches!(tip.role, TipRole::TargetRemote) && tip.ref_name.is_some()).then_some(tip.id) - }) - .collect::>(); - tips.retain(|tip| !tip.collapses_into_named_integrated_target(&named_integrated_target_ids)); -} - -/// Convert validated tips into deterministic initial traversal roots. -/// -/// The caller can provide explicit tips in any order, but queue order still -/// matters because the first item that reaches a commit owns the segment for -/// that commit. This function recreates the ordering that metadata-derived -/// traversal would have produced for workspace tips, while keeping the simpler -/// historical ordering for plain commit traversal. -/// -/// The sort is intentionally heuristic: role priority establishes the broad -/// traversal shape, workspace metadata restores stack/branch order when it is -/// available, and stable tie-breakers make equivalent inputs independent of -/// caller order. For non-workspace traversals, equal-priority tips keep caller -/// order so existing explicit traversal behavior stays predictable. -fn tips_in_queue_order( - tips: Vec, - auxiliary_integrated_tip_ids: &BTreeSet, -) -> Vec { - let has_workspace_related_tips = has_workspace_related_tips(&tips); - let workspace_branch_order = workspace_branch_order_from_tips(&tips); - let mut tips: Vec<_> = tips.into_iter().enumerate().collect(); - tips.sort_by(|(a_idx, a), (b_idx, b)| { - tip_queue_priority(a, has_workspace_related_tips, auxiliary_integrated_tip_ids) - .cmp(&tip_queue_priority( - b, - has_workspace_related_tips, - auxiliary_integrated_tip_ids, - )) - .then_with(|| { - tip_workspace_branch_order(a, &workspace_branch_order) - .cmp(&tip_workspace_branch_order(b, &workspace_branch_order)) - }) - .then_with(|| { - if has_workspace_related_tips { - tip_sort_name(a).cmp(&tip_sort_name(b)) - } else { - std::cmp::Ordering::Equal - } - }) - .then_with(|| { - if has_workspace_related_tips { - a.id.cmp(&b.id) - } else { - std::cmp::Ordering::Equal - } - }) - .then_with(|| a_idx.cmp(b_idx)) - }); - tips.into_iter().map(|(_, tip)| tip).collect() -} - -/// Return whether tip ordering has to emulate workspace metadata traversal. -/// -/// Workspace, workspace-stack, and target-local tips are not just additional -/// roots. Their relative order influences which segment owns a shared commit -/// and how post-processing reconstructs virtual workspace and stack segments. -/// Detecting such tips switches sorting from "mostly preserve caller order" to -/// "rebuild the metadata order deterministically". -fn has_workspace_related_tips(tips: &[Tip]) -> bool { - tips.iter().any(|tip| { - matches!( - tip.role, - TipRole::Workspace | TipRole::TargetLocal { .. } | TipRole::WorkspaceStackBranch { .. } - ) || matches!(tip.metadata, Some(SegmentMetadata::Workspace(_))) - }) -} - -/// Primary sort key for initial tips. -/// -/// This is the main heuristic. For workspace-related traversals we recreate -/// the metadata-derived segment creation order: -/// -/// 1. A non-workspace reachable entrypoint first, if there is one. -/// 2. The workspace ref so it can become the traversal anchor. -/// 3. The integrated target ref, then its local tracking branch, so they can -/// be linked as siblings and agree on target ownership. -/// 4. Synthetic integrated targets, like extra target commits. -/// 5. Workspace stack branches, whose order is refined later from workspace -/// metadata. -/// 6. Other reachable roots. -/// -/// For non-workspace traversals there is no metadata order to recover, so -/// integrated context still comes first, non-entry reachable roots follow, and -/// the entrypoint anchors the graph last. Synthetic integrated tips remain -/// last because they are auxiliary limits, not primary user roots. -fn tip_queue_priority( - tip: &Tip, - has_workspace_related_tips: bool, - auxiliary_integrated_tip_ids: &BTreeSet, -) -> usize { - if has_workspace_related_tips { - match &tip.role { - TipRole::Reachable if tip.is_entrypoint => 0, - TipRole::Workspace => 1, - TipRole::TargetRemote if tip.ref_name.is_some() => 2, - TipRole::TargetLocal { .. } => 3, - TipRole::TargetRemote - if tip.is_auxiliary_integrated_tip(auxiliary_integrated_tip_ids) => - { - 4 - } - TipRole::TargetRemote => 2, - TipRole::WorkspaceStackBranch { .. } => 5, - TipRole::Reachable => 6, - } - } else { - match &tip.role { - TipRole::TargetRemote - if tip.is_auxiliary_integrated_tip(auxiliary_integrated_tip_ids) => - { - 3 - } - TipRole::TargetRemote => 0, - TipRole::TargetLocal { .. } => 0, - TipRole::Reachable | TipRole::Workspace | TipRole::WorkspaceStackBranch { .. } => { - if tip.is_entrypoint { 2 } else { 1 } - } - } - } -} - -/// Recover stack-branch order from workspace metadata. -/// -/// Workspace metadata stores the user-visible ordering of workspaces, stacks, -/// and branches. When explicit tips are equivalent to metadata-derived tips, -/// this order is the only reliable way to make scrambled input produce the same -/// graph and workspace projection as `from_commit_traversal()`. -/// -/// The return value maps a branch ref name to the position where that branch -/// appears in workspace metadata. The value tuple is -/// `(workspace_order, stack_order, branch_order)`: -/// -/// - `workspace_order` is the index of the workspace metadata tip after all -/// workspace metadata tips have been sorted by their optional ref name. This -/// makes multi-workspace input deterministic even when the caller provided -/// tips in a different order. -/// - `stack_order` is the zero-based index among stacks that are currently in -/// the workspace. Archived or otherwise inactive stacks are ignored and don't -/// consume an order slot. -/// - `branch_order` is the zero-based index of the branch within that stack's -/// branch list. -/// -/// Branch refs not found in this map have no metadata-derived order and fall -/// back to later tie-breakers. If the same branch ref appears more than once, -/// the first metadata occurrence wins, matching the "first configured stack -/// owns the branch" behavior expected by workspace projection. -fn workspace_branch_order_from_tips( - tips: &[Tip], -) -> BTreeMap { - let mut workspaces: Vec<_> = tips - .iter() - .filter_map(|tip| match tip.metadata.as_ref() { - Some(SegmentMetadata::Workspace(data)) => Some((tip.ref_name.as_ref(), data)), - Some(SegmentMetadata::Branch(_)) | None => None, - }) - .collect(); - workspaces.sort_by_key(|(ref_name, _)| *ref_name); - - let mut out = BTreeMap::new(); - for (workspace_order, (_ref_name, data)) in workspaces.into_iter().enumerate() { - for (stack_order, stack) in data - .stacks - .iter() - .filter(|stack| stack.is_in_workspace()) - .enumerate() - { - for (branch_order, branch) in stack.branches.iter().enumerate() { - out.entry(branch.ref_name.clone()).or_insert(( - workspace_order, - stack_order, - branch_order, - )); - } - } - } - out -} - -/// Return the metadata order for a workspace stack branch tip. -/// -/// Only `WorkspaceStackBranch` tips participate in this secondary ordering. -/// Other roles intentionally return `None` so their relative order is governed -/// by the primary role priority and later tie-breakers. -fn tip_workspace_branch_order( - tip: &Tip, - workspace_branch_order: &BTreeMap, -) -> Option<(usize, usize, usize)> { - match &tip.role { - TipRole::WorkspaceStackBranch { desired_ref_name } => { - workspace_branch_order.get(desired_ref_name).copied() - } - TipRole::Reachable - | TipRole::Workspace - | TipRole::TargetRemote - | TipRole::TargetLocal { .. } => None, - } -} - -/// Stable name tie-breaker used only in workspace-related sorting. -/// -/// After role priority and metadata branch order, tips may still be equivalent -/// from the traversal's point of view. Sorting by the ref that will name or -/// identify the segment keeps explicit workspace-tip input order irrelevant. -/// For non-workspace traversals this helper is deliberately ignored so equal -/// priorities preserve the caller's order instead. -fn tip_sort_name(tip: &Tip) -> Option { - match &tip.role { - TipRole::WorkspaceStackBranch { desired_ref_name } => { - Some(desired_ref_name.as_bstr().to_string()) - } - TipRole::TargetLocal { local_ref_name } => Some(local_ref_name.as_bstr().to_string()), - TipRole::Reachable | TipRole::Workspace | TipRole::TargetRemote => { - tip.ref_name.as_ref().map(|ref_name| ref_name.to_string()) - } - } -} - -/// Discover workspaces, targets, local tracking branches, and workspace stack -/// branch refs and turn them into initial traversal tips. -fn initial_tips_from_workspace_metadata( - repo: &OverlayRepo<'_>, - meta: &OverlayMetadata<'_, T>, - entrypoint: gix::ObjectId, - entrypoint_ref: Option<&gix::refs::FullName>, - project_meta: &ProjectMeta, - extra_target_commit_id: Option, -) -> anyhow::Result> { - let workspaces = obtain_workspace_infos(repo, entrypoint_ref.map(|rn| rn.as_ref()), meta)?; - let tip_ref_matches_ws_ref = workspaces - .iter() - .find_map(|(ws_tip, ws_rn, _)| (Some(ws_rn) == entrypoint_ref).then_some(ws_tip)); - - let mut tips = Vec::new(); - let mut workspace_metas = Vec::new(); - let mut additional_target_commits = Vec::new(); - let mut queued_ids = Vec::new(); - - match tip_ref_matches_ws_ref { - None => { - // We don't name the tip of the entrypoint as we want the segment - // naming to be handled by tips created from metadata. - tips.push(Tip::entrypoint(entrypoint, None)); - queued_ids.push(entrypoint); - } - Some(ws_tip) => { - ensure!( - *ws_tip == entrypoint, - format!( - "BUG:: {entrypoint_ref:?} points to {ws_tip}, but the caller claimed it points to {entrypoint}" - ) - ); - } - } - - for (ws_tip, ws_ref, ws_meta) in workspaces { - workspace_metas.push(ws_meta.clone()); - additional_target_commits.extend(project_meta.target_commit_id); - tips.push( - Tip::new(ws_tip) - .with_ref_name(Some(ws_ref.clone())) - .with_role(TipRole::Workspace) - .with_metadata(SegmentMetadata::Workspace(ws_meta.clone())) - .with_is_entrypoint(Some(&ws_ref) == entrypoint_ref), - ); - - let target = if let Some((target_ref, target_ref_id, local_info)) = - workspace_target_tip(repo, project_meta.target_ref.as_ref())? - { - let local_info = - local_info.filter(|(_local_ref_name, local_tip)| !queued_ids.contains(local_tip)); - tips.push( - Tip::new(target_ref_id) - .with_ref_name(Some(target_ref)) - .with_role(TipRole::TargetRemote), - ); - if let Some((local_ref_name, local_tip)) = local_info.clone() { - tips.push(Tip::new(local_tip).with_role(TipRole::TargetLocal { local_ref_name })); - } - Some(( - target_ref_id, - local_info.map(|(_local_ref_name, local_tip)| local_tip), - )) - } else { - None - }; - queued_ids.push(ws_tip); - if let Some((target_ref_id, local_tip)) = target { - queued_ids.push(target_ref_id); - if let Some(local_tip) = local_tip { - queued_ids.push(local_tip); - } - } - } - - if let Some(extra_target) = extra_target_commit_id { - push_integrated_tip_once(&mut tips, extra_target); - } - - for target_commit_id in additional_target_commits { - // These are possibly from metadata, and thus might not exist (anymore). Ignore if that's the case. - if let Err(err) = repo.find_commit(target_commit_id) { - tracing::warn!( - ?target_commit_id, - ?err, - "Ignoring stale target commit id as it didn't exist" - ); - continue; - } - // We don't really have a place to store the segment index of the segment owning the target commit - // so we will re-acquire it later when building the workspace projection. - push_integrated_tip_once(&mut tips, target_commit_id); - } - - // Queue workspace stack branch refs that may have advanced since the - // workspace commit was written, and thus would not be reached from that - // commit alone. - for ws_metadata in workspace_metas { - for segment in ws_metadata - .stacks - .into_iter() - .filter(|s| s.is_in_workspace()) - .flat_map(|s| s.branches.into_iter()) - { - let Some(segment_tip) = repo - .try_find_reference(segment.ref_name.as_ref())? - .map(|mut r| r.peel_to_id()) - .transpose()? - else { - continue; - }; - push_tip_once( - &mut tips, - Tip::new(segment_tip.detach()).with_role(TipRole::WorkspaceStackBranch { - desired_ref_name: segment.ref_name, - }), - ); - } - } - - Ok(tips) -} - -fn push_integrated_tip_once(tips: &mut Vec, id: gix::ObjectId) { - let tip = Tip::new(id).with_role(TipRole::TargetRemote); - push_tip_once(tips, tip); -} - -fn push_tip_once(tips: &mut Vec, tip: Tip) { - if !tips - .iter() - .any(|existing| tips_have_same_traversal_seed(existing, &tip)) - { - tips.push(tip); - } -} - -/// Resolve a workspace target ref and, when possible, its local tracking branch -/// tip. -fn workspace_target_tip( - repo: &OverlayRepo<'_>, - target_ref: Option<&gix::refs::FullName>, -) -> anyhow::Result> { - let Some(target_ref) = target_ref else { - return Ok(None); - }; - let target_ref_id = match try_refname_to_id(repo, target_ref.as_ref()).map_err(|err| { - tracing::warn!("Ignoring non-existing target branch {target_ref}: {err}"); - err - }) { - Ok(Some(target_ref_id)) => target_ref_id, - Ok(None) | Err(_) => return Ok(None), - }; - let local_info = repo - .upstream_branch_and_remote_for_tracking_branch(target_ref.as_ref()) - .ok() - .flatten() - .and_then(|(local_tracking_name, _remote_name)| { - let target_local_tip = try_refname_to_id(repo, local_tracking_name.as_ref()).ok()??; - Some((local_tracking_name, target_local_tip)) - }); - Ok(Some((target_ref.clone(), target_ref_id, local_info))) -} - -/// Return remote target refs that are already represented by initial tips. -/// -/// The result is passed to remote-tracking discovery so it does not queue a -/// target ref a second time when walking a local branch that tracks it. -/// Workspace traversals get this from the project metadata target ref, which -/// is where their target lives now. Explicit traversals have no workspace -/// discovery source, so named integrated tips may also act as target refs -/// when `include_integrated_tip_refs` is set. -fn target_refs_from_tips( - tips: &[Tip], - project_meta: &ProjectMeta, - include_integrated_tip_refs: bool, -) -> Vec { - let has_workspace_metadata_tip = tips - .iter() - .any(|tip| matches!(tip.metadata, Some(SegmentMetadata::Workspace(_)))); - let mut target_refs: Vec<_> = tips - .iter() - .filter(|tip| include_integrated_tip_refs && tip.role.is_integrated()) - .filter_map(|tip| tip.ref_name.clone()) - .chain( - has_workspace_metadata_tip - .then(|| project_meta.target_ref.clone()) - .flatten(), - ) - .collect(); - target_refs.sort(); - target_refs.dedup(); - target_refs -} - -/// Infer target remote/local tracking links without exposing correlation ids on -/// public tips. -/// -/// The target side is represented by a named [`TipRole::TargetRemote`] tip. The -/// local side is represented by a [`TipRole::TargetLocal`] tip whose -/// `local_ref_name` matches the local branch configured to track that remote -/// target ref. If either side is absent, the tips still participate in -/// traversal but no sibling link is prepared up front. -fn target_local_links_from_tips(repo: &OverlayRepo<'_>, tips: &[Tip]) -> TargetLocalLinks { - let remote_target_refs: Vec<_> = tips - .iter() - .filter(|tip| matches!(tip.role, TipRole::TargetRemote)) - .filter_map(|tip| tip.ref_name.clone()) - .collect(); - let local_refs: BTreeSet<_> = tips - .iter() - .filter_map(|tip| match &tip.role { - TipRole::TargetLocal { local_ref_name } => Some(local_ref_name.clone()), - TipRole::Reachable - | TipRole::Workspace - | TipRole::WorkspaceStackBranch { .. } - | TipRole::TargetRemote => None, - }) - .collect(); - - let mut links = TargetLocalLinks::default(); - for target_ref in remote_target_refs { - let Some((local_ref, _remote_name)) = repo - .upstream_branch_and_remote_for_tracking_branch(target_ref.as_ref()) - .ok() - .flatten() - else { - continue; - }; - if !local_refs.contains(&local_ref) { - continue; - } - links - .local_by_target - .insert(target_ref.clone(), local_ref.clone()); - links.target_by_local.insert(local_ref, target_ref); - } - links -} - -/// Collect symbolic remote names implied by tip refs, workspace target refs, -/// workspace `push_remote` settings, and stack branch refs. -fn symbolic_remote_names_from_tips( - repo: &OverlayRepo<'_>, - tips: &[Tip], - project_meta: &ProjectMeta, - include_tip_refs: bool, -) -> Vec { - let remote_names = repo.remote_names(); - let refs = tips - .iter() - .filter_map(|tip| include_tip_refs.then_some(tip.ref_name.as_ref()).flatten()) - .filter_map({ - let remote_names = &remote_names; - move |ref_name| { - extract_remote_name_and_short_name(ref_name.as_ref(), remote_names) - .map(|(remote, _short_name)| (1, remote)) - } - }); - let workspace_metadata_names = tips - .iter() - .filter_map(|tip| match tip.metadata.as_ref() { - Some(SegmentMetadata::Workspace(data)) => Some(data), - Some(SegmentMetadata::Branch(_)) | None => None, - }) - .flat_map(|data| { - data.stacks.iter().flat_map(|s| { - s.branches.iter().flat_map(|b| { - extract_remote_name_and_short_name(b.ref_name.as_ref(), &remote_names) - .map(|(remote, _short_name)| (1, remote)) - }) - }) - }); - let desired_refs = tips.iter().filter_map(|tip| match &tip.role { - _ if !include_tip_refs => None, - TipRole::WorkspaceStackBranch { desired_ref_name } => { - extract_remote_name_and_short_name(desired_ref_name.as_ref(), &remote_names) - .map(|(remote, _short_name)| (1, remote)) - } - TipRole::Reachable - | TipRole::Workspace - | TipRole::TargetLocal { .. } - | TipRole::TargetRemote => None, - }); - let target_ref = project_meta.target_ref.as_ref().and_then(|target_ref| { - extract_remote_name_and_short_name(target_ref.as_ref(), &remote_names) - .map(|(remote, _short_name)| (1, remote)) - }); - let push_remote = project_meta - .push_remote - .as_ref() - .map(|push_remote| (0, push_remote.clone())); - sorted_symbolic_remote_names( - refs.chain(workspace_metadata_names) - .chain(desired_refs) - .chain(target_ref) - .chain(push_remote), - ) -} - -/// Sort and deduplicate remote names, preserving explicit push remotes before -/// remotes inferred from refs with the same name. -fn sorted_symbolic_remote_names(names: impl Iterator) -> Vec { - let mut names: Vec<_> = names.collect(); - names.sort(); - names.dedup(); - names.into_iter().map(|(_order, remote)| remote).collect() -} - -/// Insert initial segments, seed the traversal queue, and return workspace -/// ownership roots for post-processing. -#[expect(clippy::too_many_arguments)] -fn queue_initial_tips( - graph: &mut Graph, - next: &mut Queue, - initial_tips: &InitialTips, - entrypoint: gix::ObjectId, - entrypoint_flags: CommitFlags, - max_limit: Limit, - target_limit: Limit, - goals: &mut Goals, - commit_graph: Option<&gix::commitgraph::Graph>, - repo: &OverlayRepo<'_>, - meta: &OverlayMetadata<'_, T>, - ctx: &post::Context<'_>, - buf: &mut Vec, -) -> anyhow::Result> { - // `target_local_segments` holds the local side once its segment and goal - // exist. `pending_integrated_tips` holds the remote target side if it - // appears first. Both maps are keyed by target remote ref names inferred - // from tip refs and repository branch configuration. - let mut target_local_segments = - BTreeMap::, CommitFlags)>::new(); - let mut pending_integrated_tips = BTreeMap::::new(); - - for tip in &initial_tips.tips { - match &tip.role { - TipRole::WorkspaceStackBranch { .. } if next.iter().any(|t| t.0.id == tip.id) => { - next.add_goal_to(tip.id, goals.flag_for(entrypoint).unwrap_or_default()); - continue; - } - TipRole::TargetRemote - if tip.is_auxiliary_integrated_tip(&initial_tips.auxiliary_integrated_tip_ids) - && next.iter().any(|(info, _, _, _)| info.id == tip.id) => - { - continue; - } - _ => {} - } - - let mut segment = branch_segment_from_name_and_meta( - tip.ref_name - .clone() - .map(|ref_name| (ref_name, tip.metadata.clone())), - meta, - Some((&ctx.refs_by_id, tip.id)), - &ctx.worktree_by_branch, - )?; - if let TipRole::WorkspaceStackBranch { desired_ref_name } = &tip.role { - let is_remote = desired_ref_name - .category() - .is_some_and(|c| c == Category::RemoteBranch); - if segment.ref_info.is_none() && is_remote { - segment.ref_info = Some(crate::RefInfo::from_ref( - desired_ref_name.clone(), - tip.id, - &ctx.worktree_by_branch, - )); - segment.metadata = meta - .branch_opt(desired_ref_name.as_ref())? - .map(SegmentMetadata::Branch); - } - } - let segment = graph.insert_segment(segment); - if let TipRole::TargetRemote = &tip.role { - let pending = PendingIntegratedTip { - id: tip.id, - segment, - queue_front: queue_should_frontload_tip( - tip, - initial_tips.frontload_workspace_related_tips, - &initial_tips.auxiliary_integrated_tip_ids, - ), - }; - if let Some(target_ref) = tip - .ref_name - .as_ref() - .filter(|ref_name| { - initial_tips - .target_local_links - .local_by_target - .contains_key(*ref_name) - }) - .cloned() - { - let Some(local) = target_local_segments.get(&target_ref).copied() else { - pending_integrated_tips.insert(target_ref, pending); - continue; - }; - queue_pending_integrated_tip( - graph, - next, - pending, - local, - target_limit, - commit_graph, - repo, - buf, - )?; - } else { - queue_pending_integrated_tip( - graph, - next, - pending, - (None, CommitFlags::empty()), - target_limit, - commit_graph, - repo, - buf, - )?; - } - continue; - } - - let (flags, limit) = match &tip.role { - TipRole::Reachable if tip.is_entrypoint => { - graph.entrypoint = Some((segment, EntryPointCommit::AtCommit(tip.id))); - (entrypoint_flags, max_limit) - } - TipRole::Reachable => { - reachable_tip_flags_and_limit(tip.id, entrypoint, max_limit, goals) - } - TipRole::TargetRemote => unreachable!("handled above"), - TipRole::Workspace => { - if tip.is_entrypoint && graph.entrypoint.is_none() { - graph.entrypoint = Some((segment, EntryPointCommit::AtCommit(tip.id))); - } - let extra_flags = if tip.is_entrypoint { - entrypoint_flags - } else { - CommitFlags::empty() - }; - let limit = if tip.is_entrypoint { - max_limit - } else { - max_limit.with_indirect_goal(entrypoint, goals) - }; - ( - CommitFlags::InWorkspace | CommitFlags::NotInRemote | extra_flags, - limit, - ) - } - TipRole::TargetLocal { local_ref_name } => { - let has_remote_link = { - let s = &graph[segment]; - s.ref_name() - .is_some_and(|ref_name| ref_name == local_ref_name.as_ref()) - }; - let goal = goals.flag_for(tip.id).unwrap_or_default(); - if let Some(target_ref) = initial_tips - .target_local_links - .target_by_local - .get(local_ref_name) - { - target_local_segments.insert( - target_ref.clone(), - (has_remote_link.then_some(segment), goal), - ); - } - next.add_goal_to(entrypoint, goal); - (CommitFlags::NotInRemote | goal, target_limit) - } - TipRole::WorkspaceStackBranch { .. } => ( - CommitFlags::NotInRemote, - max_limit.with_indirect_goal(entrypoint, goals), - ), - }; - let tip_info = find(commit_graph, repo.for_find_only(), tip.id, buf)?; - let item = ( - tip_info, - flags, - Instruction::CollectCommit { into: segment }, - limit, - ); - // A target ref and its local tracking branch can point at the same - // commit. In that case, the integrated target was held back only until - // the local side created its segment and goal above. Queue the - // integrated item before pushing the current local item so the shared - // commit is owned as integrated history while still carrying the local - // goal that lets both sides connect. - let paired_target_ref = match &tip.role { - TipRole::TargetLocal { local_ref_name } => initial_tips - .target_local_links - .target_by_local - .get(local_ref_name) - .cloned(), - TipRole::Reachable - | TipRole::Workspace - | TipRole::WorkspaceStackBranch { .. } - | TipRole::TargetRemote => None, - }; - let pending_before_current = paired_target_ref.as_ref().and_then(|target_ref| { - pending_integrated_tips - .get(target_ref) - .is_some_and(|pending| pending.id == tip.id) - .then(|| pending_integrated_tips.remove(target_ref)) - .flatten() - }); - if let Some(pending) = pending_before_current { - let local = paired_target_ref - .as_ref() - .and_then(|target_ref| target_local_segments.get(target_ref)) - .copied() - .unwrap_or((None, CommitFlags::empty())); - queue_pending_integrated_tip( - graph, - next, - pending, - local, - target_limit, - commit_graph, - repo, - buf, - )?; - } - if queue_should_frontload_tip( - tip, - initial_tips.frontload_workspace_related_tips, - &initial_tips.auxiliary_integrated_tip_ids, - ) { - _ = next.push_front_exhausted(item); - } else { - _ = next.push_back_exhausted(item); - } - - if let Some(target_ref) = paired_target_ref - && let Some(pending) = pending_integrated_tips.remove(&target_ref) - { - let local = target_local_segments - .get(&target_ref) - .copied() - .unwrap_or((None, CommitFlags::empty())); - queue_pending_integrated_tip( - graph, - next, - pending, - local, - target_limit, - commit_graph, - repo, - buf, - )?; - } - } - - prioritize_initial_tips_and_assure_ws_commit_ownership( - graph, - next, - (initial_tips.workspace_tips.clone(), repo, meta), - &ctx.worktree_by_branch, - ) -} - -/// Queue an integrated target after optionally linking it to its local tracking segment. -#[expect(clippy::too_many_arguments)] -fn queue_pending_integrated_tip( - graph: &mut Graph, - next: &mut Queue, - pending: PendingIntegratedTip, - local: (Option, CommitFlags), - target_limit: Limit, - commit_graph: Option<&gix::commitgraph::Graph>, - repo: &OverlayRepo<'_>, - buf: &mut Vec, -) -> anyhow::Result<()> { - let (local_sidx, local_goal) = local; - if let Some(local_sidx) = local_sidx { - graph[local_sidx].remote_tracking_branch_segment_id = Some(pending.segment); - graph[pending.segment].sibling_segment_id = Some(local_sidx); - } - let tip_info = find(commit_graph, repo.for_find_only(), pending.id, buf)?; - let item = ( - tip_info, - CommitFlags::Integrated, - Instruction::CollectCommit { - into: pending.segment, - }, - target_limit.additional_goal(local_goal), - ); - if pending.queue_front { - _ = next.push_front_exhausted(item); - } else { - _ = next.push_back_exhausted(item); - } - Ok(()) -} - -/// Return whether an initial queue item should be pushed to the front. -/// -/// This is the second half of the ordering heuristic. `tips_in_queue_order()` -/// decides the order in which initial segments are created. Once those segments -/// are converted into traversal queue items, some roles must still be -/// front-loaded so their commits are visited before ordinary reachable or stack -/// branch work that may point at the same commits so they can own them. -/// -/// Synthetic integrated tips are always front-loaded because they represent -/// additional target/limit commits rather than user-visible branch roots. For -/// workspace-related traversals, workspace, integrated target, and target-local -/// tips are also front-loaded so target ownership and target/local sibling -/// links are established before stack-branch traversal can claim shared commits. -/// Workspace stack branches are deliberately not front-loaded: their segment -/// creation order is recovered from metadata, but their traversal work should -/// follow the workspace/target context. -fn queue_should_frontload_tip( - tip: &Tip, - frontload_workspace_related_tips: bool, - auxiliary_integrated_tip_ids: &BTreeSet, -) -> bool { - tip.is_auxiliary_integrated_tip(auxiliary_integrated_tip_ids) - || (frontload_workspace_related_tips - && matches!( - tip.role, - TipRole::Workspace | TipRole::TargetRemote | TipRole::TargetLocal { .. } - )) -} - -/// Return the flags and limit used by a reachable tip seeking the entrypoint. -fn reachable_tip_flags_and_limit( - tip: gix::ObjectId, - entrypoint: gix::ObjectId, - max_limit: Limit, - goals: &mut Goals, -) -> (CommitFlags, Limit) { - let limit = if tip == entrypoint { - max_limit - } else { - max_limit.with_indirect_goal(entrypoint, goals) - }; - (CommitFlags::NotInRemote, limit) -} - -impl Graph { - /// Connect two existing segments `src` from `src_commit` to point `dst_commit` of `b`. - pub(crate) fn connect_segments( - &mut self, - src: SegmentIndex, - src_commit: impl Into>, - dst: SegmentIndex, - dst_commit: impl Into>, - ) { - self.connect_segments_with_ids(src, src_commit, None, dst, dst_commit, None, 0) - } - - #[expect(clippy::too_many_arguments)] - 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, - parent_order: u32, - ) { - let src_commit = src_commit.into(); - let dst_commit = dst_commit.into(); - let new_edge_id = self.inner.add_edge( - src, - dst, - Edge { - src: 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)), - parent_order, - }, - ); - self.rebuild_outgoing_edges_for_traversal_order(src, new_edge_id); - } - - fn rebuild_outgoing_edges_for_traversal_order( - &mut self, - src: SegmentIndex, - new_edge_id: petgraph::stable_graph::EdgeIndex, - ) { - let mut new_edge = None; - let mut outgoing_edges = Vec::new(); - for edge in self.inner.edges_directed(src, Direction::Outgoing) { - let edge = EdgeOwned::from(edge); - if edge.id == new_edge_id { - new_edge = Some(edge); - } else { - outgoing_edges.push(edge); - } - } - - let Some(new_edge) = new_edge else { - return; - }; - if outgoing_edges.is_empty() { - return; - } - - let insert_at = outgoing_edges - .partition_point(|edge| edge.weight.parent_order <= new_edge.weight.parent_order); - outgoing_edges.insert(insert_at, new_edge); - - for edge in &outgoing_edges { - self.inner.remove_edge(edge.id); - } - for edge in outgoing_edges.into_iter().rev() { - self.inner.add_edge(edge.source, edge.target, edge.weight); - } - } -} diff --git a/crates/but-graph/src/init/post.rs b/crates/but-graph/src/init/post.rs deleted file mode 100644 index c5e47549989..00000000000 --- a/crates/but-graph/src/init/post.rs +++ /dev/null @@ -1,2204 +0,0 @@ -use std::collections::{BTreeMap, BTreeSet}; - -use anyhow::{Context as _, bail}; -use but_core::{ - RefMetadata, ref_metadata, - ref_metadata::StackKind::{Applied, AppliedAndUnapplied}, -}; -use gix::{ObjectId, prelude::ObjectIdExt, reference::Category}; -use itertools::Itertools; -use petgraph::{Direction, graph::NodeIndex, prelude::EdgeRef, visit::NodeRef}; -use tracing::instrument; - -use crate::{ - Commit, CommitFlags, CommitIndex, Edge, EntryPointCommit, Graph, SegmentIndex, SegmentMetadata, - init::{ - PetGraph, TipRole, branch_segment_from_name_and_meta, - overlay::{OverlayMetadata, OverlayRepo}, - remotes, - types::{EdgeOwned, TopoWalk}, - walk::{RefsById, WorktreeByBranch, disambiguate_refs_by_branch_metadata}, - }, - utils::SegmentVisitScratch, -}; - -pub(super) struct Context<'a> { - pub repo: &'a OverlayRepo<'a>, - pub symbolic_remote_names: &'a [String], - pub configured_remote_tracking_branches: &'a BTreeSet, - pub inserted_proxy_segments: Vec, - pub refs_by_id: RefsById, - pub hard_limit: bool, - pub detach_entrypoint: bool, - pub dangerously_skip_postprocessing_for_debugging: bool, - pub worktree_by_branch: WorktreeByBranch, -} - -/// Processing -impl Graph { - /// Now that the graph is complete, perform additional structural improvements with - /// the requirement of them to be computationally cheap. - #[instrument(level = "trace", skip_all, fields(tip), err(Debug))] - pub(super) fn post_processed( - mut self, - meta: &OverlayMetadata<'_, T>, - tip: gix::ObjectId, - Context { - repo, - symbolic_remote_names, - configured_remote_tracking_branches, - inserted_proxy_segments, - refs_by_id, - hard_limit, - detach_entrypoint, - dangerously_skip_postprocessing_for_debugging, - worktree_by_branch, - }: Context<'_>, - ) -> anyhow::Result { - self.hard_limit_hit = hard_limit; - - // Make sure edge order matches the git commit graph when traversed. - self.rebuild_edges_in_parent_order(); - - // Keep the original traversal tip available even if post-processing moves the - // entrypoint to a segment that doesn't contain it. - self.update_entrypoint_commit_id(tip); - - if dangerously_skip_postprocessing_for_debugging { - return self.finish_post_processing(detach_entrypoint); - } - - // Before anything, cleanup the graph. - self.fixup_remote_tracking_refs_and_maybe_split_segments(meta, &worktree_by_branch)?; - - // This should be first as what follows could help name these new segments that it creates. - self.fixup_workspace_segments(repo, &refs_by_id, meta, &worktree_by_branch)?; - // All non-workspace fixups must come first, otherwise the workspace handling might - // differ as it relies on non-anonymous segments much more. - self.fixup_segment_names(meta, &inserted_proxy_segments, &worktree_by_branch); - // We perform view-related updates here for convenience, but also because the graph - // traversal should have nothing to do with workspace details. It's just about laying - // the foundation for figuring out our workspaces more easily. - self.workspace_upgrades(meta, repo, &refs_by_id, &worktree_by_branch)?; - self.ad_hoc_branch_stack_upgrades(repo, meta, &worktree_by_branch)?; - - // Point entrypoint to the right spot after all the virtual branches were added. - self.set_entrypoint_to_ref_name(meta)?; - - // However, when it comes to using remotes to disambiguate, it's better to - // *not* do that before workspaces are sorted as it might incorrectly place - // a segment on top of another one, setting a first-second relationship that isn't - // what we have in the workspace metadata, which then also can't set it anymore - // because it can't reorder existing empty segments (which are not natural). - self.improve_remote_segments( - repo, - meta, - symbolic_remote_names, - configured_remote_tracking_branches, - &worktree_by_branch, - )?; - - // Finally, once all segments were added, it's good to generations - // have to figure out early abort conditions, or to know what's ahead of another. - self.compute_generation_numbers(); - - self.symbolic_remote_names = symbolic_remote_names.to_vec(); - self.finish_post_processing(detach_entrypoint) - } - - fn finish_post_processing(mut self, detach_entrypoint: bool) -> anyhow::Result { - if detach_entrypoint { - self.detach_entrypoint_segment()?; - } - Ok(self) - } - - /// Rebuild outgoing edges so petgraph traversal follows the source commit's - /// `parent_order`. Petgraph traverses edges in reverse creation order, so - /// edges are re-added from last parent to first parent. - fn rebuild_edges_in_parent_order(&mut self) { - let mut outgoing_edges = Vec::with_capacity(2); - for sidx in self.segments().collect::>() { - let mut edges = self.inner.edges_directed(sidx, Direction::Outgoing); - let Some(first_edge) = edges.next() else { - continue; - }; - let Some(second_edge) = edges.next() else { - continue; - }; - - outgoing_edges.clear(); - outgoing_edges.push(EdgeOwned::from(first_edge)); - outgoing_edges.push(EdgeOwned::from(second_edge)); - outgoing_edges.extend(edges.map(EdgeOwned::from)); - - outgoing_edges.sort_by_key(|e| std::cmp::Reverse(e.weight.parent_order)); - - for edge in &outgoing_edges { - self.inner.remove_edge(edge.id); - } - for edge in &outgoing_edges { - self.inner.add_edge(edge.source, edge.target, edge.weight); - } - } - } - - /// Ensure the entrypoint commit-id is updated to match the actual tip commit. - /// This may get out of sync when we do our graph manipulations, and it's easier - /// to set it right in post than to let everything deal with this. - fn update_entrypoint_commit_id(&mut self, tip: ObjectId) { - if let Some((_segment, ep_commit)) = self.entrypoint.as_mut() { - *ep_commit = EntryPointCommit::AtCommit(tip); - } - } - - /// After everything, ensure the entrypoint still points to a segment with - /// the correct ref-name if one was given when starting the traversal. - /// This is only relevant if a ref-name is given at all. - /// - /// If the the entrypoint ref doesn't match the ref specified at the start - /// we: - /// - If a segment named with the desired ref is found, we update the - /// entrypoint to match that - /// - If any segment whose first commit owns a ref with the desired name - /// - If the segment has no ref_info, we remove the ref from the commit - /// and set the ref_info on the segment, before setting the segment as the - /// entrypoint. - /// - Otherwise we split it out into a new segment and set that new - /// entrypoint. - /// - Otherwise, it's an error if no segment is named after the entrypoint-ref, - /// or has a commit that holds it. - /// - /// *This is the brute-force way of doing it, instead of ensuring that the - /// workspace upgrade functions that create independent and dependent - /// branches keep everything up-to-date at all times. - fn set_entrypoint_to_ref_name( - &mut self, - meta: &OverlayMetadata<'_, T>, - ) -> anyhow::Result<()> { - let Some(((ep_sidx, ep_commit), desired_ref_name)) = - self.entrypoint.zip(self.entrypoint_ref.clone()) - else { - return Ok(()); - }; - - let ep_segment_is_correctly_named = self[ep_sidx] - .ref_name() - .is_some_and(|rn| rn == desired_ref_name.as_ref()); - if ep_segment_is_correctly_named { - return Ok(()); - } - - let (sidx_with_desired_name, sidx_with_first_commit_with_desired_name) = self - .node_weights() - .find_map(|s| { - s.ref_name() - .is_some_and(|rn| rn == desired_ref_name.as_ref()) - .then_some((Some(s.id), None)) - .or_else(|| { - s.commits.first().and_then(|c| { - c.refs - .iter() - .position(|ri| ri.ref_name == desired_ref_name) - .map(|pos| (None, Some((s.id, pos)))) - }) - }) - }) - .unwrap_or_default(); - if let Some(new_ep_sidx) = sidx_with_desired_name { - self.entrypoint = Some((new_ep_sidx, ep_commit)); - } else if let Some((new_ep_sidx, ref_idx)) = sidx_with_first_commit_with_desired_name { - let s = &mut self.inner[new_ep_sidx]; - let desired_ref_name = s - .commits - .first_mut() - .context("BUG: we have ref_idx because the first commit was checked")? - .refs - .remove(ref_idx); - if s.ref_info.is_some() { - // ref-name is known to not be the desired one, and the first commit of this segment has the name - // we seek. For that, we now create a new - let new_ep_sidx_first_commit_idx = s.commits.first().map(|_| 0); - let incoming_edges = collect_edges_at_commit_in_traversal_order( - &self.inner, - (new_ep_sidx, new_ep_sidx_first_commit_idx), - Direction::Incoming, - ); - let mut entrypoint_segment = branch_segment_from_name_and_meta( - Some((desired_ref_name.ref_name, None)), - meta, - None, - &Default::default(), - )?; - if let Some(ri) = entrypoint_segment.ref_info.as_mut() { - ri.commit_id = desired_ref_name.commit_id; - ri.worktree = desired_ref_name.worktree; - } - let entrypoint_sidx = self.insert_segment_set_entrypoint(entrypoint_segment); - self.connect_segments( - entrypoint_sidx, - None, - new_ep_sidx, - new_ep_sidx_first_commit_idx, - ); - // Preserve incoming traversal order after moving the edges to - // the new entrypoint segment. - for edge in incoming_edges.into_iter().rev() { - self.inner.add_edge( - edge.source, - entrypoint_sidx, - Edge { - src: edge.weight.src, - src_id: edge.weight.src_id, - dst: None, - dst_id: None, - parent_order: edge.weight.parent_order, - }, - ); - self.inner.remove_edge(edge.id); - } - self.entrypoint = Some((entrypoint_sidx, ep_commit)); - } else { - let entrypoint_commit_id = s - .commits - .first() - .context("BUG: the desired entry-point ref was found on the first commit/segment empty unexpectedly")? - .id; - self.entrypoint = Some(( - new_ep_sidx, - EntryPointCommit::AtCommit(entrypoint_commit_id), - )); - // It's really important to get the name, as the HEAD is pointing to this segment. - // So find the segment with the ambiguous ref we desire, and rewrite it to be non-ambiguous. - // The reason we wait till now is to not disturb the workspace upgrades, which act differently - // if they already have a named segment. - // Note that any ref type works here, we just do as we are told even though tags for instance aren't usually - // pointed to by HEAD. - s.ref_info = Some(desired_ref_name); - } - } else { - bail!( - "BUG: Couldn't find any segment that was named after the entrypoint ref name or contained its name '{desired_ref_name}'", - ); - }; - Ok(()) - } - - /// This is a post-process as only in the end we are sure what is a remote - /// commit. - /// - /// We want to further segment remote tracking segments to avoid picking - /// up too many remote commits later. - /// - /// The purpose here is to *restore* named segments for remote tracking branches - /// as during traversal, such segments are only created if their local tracking branches - /// were reachable through the entrypoint. This is done so that remote tracking branches - /// will not make segment naming ambiguous unnecessarily, i.e. we prefer segments named - /// by local branches right now. - /// - /// Having additional segmentation caused by remote tracking branches is valuable - /// as it can reduce the amount of remote-commits that are attributed to the remote - /// tracking branch of a workspace commit, which is relevant for stacks in particular. - /// - /// We drop all remote-tracking branch references from each commit's `refs` - /// list. For remote commits that are not the first commit in a - /// multi-commit segment, we preserve the first remote-tracking reference by - /// splitting the containing segment there, with the new segment being named - /// with that reference. Additional remote-tracking references at the same - /// commit become named empty virtual segments pointing to the owning remote - /// segment. They are not 'inline', and merely there as visual evidence of what - /// happened here during debugging. For now, no algorithm discovers references - /// by checking incoming connections, instead they are expected to be either - /// inline, i.e. virutal branch segments in a workspace, or they are - /// named segments. - fn fixup_remote_tracking_refs_and_maybe_split_segments( - &mut self, - meta: &OverlayMetadata<'_, T>, - worktree_by_branch: &WorktreeByBranch, - ) -> anyhow::Result<()> { - struct SplitInfo { - segment: SegmentIndex, - commit: CommitIndex, - owner_ref: gix::refs::FullName, - /// Additional remote-tracking refs at `commit` that should become - /// named empty virtual segments pointing to `owner_ref`. - virtual_refs: Vec, - } - - let mut split_info = Vec::::new(); - for node in self.node_weights_mut() { - let node_has_commits = node.commits.len() > 1; - for (cidx, commit_with_refs) in node - .commits - .iter_mut() - .enumerate() - .filter_map(|t| (!t.1.refs.is_empty()).then_some(t)) - { - let is_splittable = - commit_with_refs.flags.is_remote() && node_has_commits && cidx > 0; - commit_with_refs.refs.retain(|ri| { - ri.ref_name.category().is_none_or(|c| { - if matches!(c, Category::RemoteBranch) { - // Always remove the ref, but keep info to create a split if possible. - if is_splittable { - if let Some(info) = split_info - .iter_mut() - .find(|info| info.segment == node.id && info.commit == cidx) - { - info.virtual_refs.push(ri.ref_name.clone()); - } else { - split_info.push(SplitInfo { - segment: node.id, - commit: cidx, - owner_ref: ri.ref_name.clone(), - virtual_refs: Vec::new(), - }); - } - } - false - } else { - true - } - }) - }) - } - } - - for info in split_info.into_iter().rev() { - let owner_sidx = self.split_segment( - info.segment, - info.commit, - Some(info.owner_ref), - None, - meta, - worktree_by_branch, - )?; - let owner_tip = self[owner_sidx] - .commits - .first() - .context("BUG: remote split segment should own at least one commit")? - .id; - - for virtual_segment_name in info.virtual_refs { - let virtual_segment = crate::Segment { - ref_info: Some(crate::RefInfo::from_ref( - virtual_segment_name, - owner_tip, - worktree_by_branch, - )), - ..Default::default() - }; - let virtual_sidx = self.insert_segment(virtual_segment); - self.connect_segments_with_ids( - virtual_sidx, - None, - None, - owner_sidx, - 0, - Some(owner_tip), - 0, - ); - } - } - Ok(()) - } - - /// Ensure that workspace segments with managed commits only have that commit, and move all others - /// into a new segment. - fn fixup_workspace_segments( - &mut self, - repo: &OverlayRepo<'_>, - refs_by_id: &RefsById, - meta: &OverlayMetadata<'_, T>, - worktree_by_branch: &WorktreeByBranch, - ) -> anyhow::Result<()> { - let workspace_segments_with_multiple_commits: Vec<_> = self - .inner - .node_indices() - .filter(|sidx| { - let s = &self[*sidx]; - s.workspace_metadata().is_some() && s.commits.len() > 1 - }) - .collect(); - - for ws_sidx in workspace_segments_with_multiple_commits { - let s = &mut self[ws_sidx]; - let first_commit = &mut s.commits[0]; - if !crate::workspace::commit::is_managed_workspace_by_message( - repo.find_commit(first_commit.id)?.message_raw()?, - ) { - continue; - } - - self.split_segment(ws_sidx, 1, None, Some(refs_by_id), meta, worktree_by_branch)?; - } - Ok(()) - } - - fn split_segment( - &mut self, - sidx: NodeIndex, - cidx_for_new_segment: CommitIndex, - segment_name: Option, - refs_by_id: Option<&RefsById>, - meta: &OverlayMetadata<'_, T>, - worktree_by_branch: &WorktreeByBranch, - ) -> anyhow::Result { - let s = &mut self[sidx]; - let tip_of_new_segment = s - .commits - .get(cidx_for_new_segment) - .with_context(|| { - format!( - "Segment {sidx:?} \ - has only {} commit(s), cannot split at {cidx_for_new_segment}", - s.commits.len() - ) - })? - .id; - let new_segment_commits = s.commits.drain(cidx_for_new_segment..).collect(); - let last_cidx_in_top_segment = s.last_commit_index(); - let edges_to_reconnect: Vec<_> = self - .inner - .edges_directed(sidx, Direction::Outgoing) - .map(EdgeOwned::from) - .collect(); - let mut new_segment = branch_segment_from_name_and_meta( - segment_name.map(|sn| (sn, None)), - meta, - refs_by_id.map(|lut| (lut, tip_of_new_segment)), - worktree_by_branch, - )?; - new_segment.commits = new_segment_commits; - let new_segment_sidx = self.connect_new_segment( - sidx, - last_cidx_in_top_segment, - new_segment, - 0, - tip_of_new_segment, - 0, - ); - - let (src, src_id) = { - let s = &self[new_segment_sidx]; - let last = s.commits.len() - 1; - (Some(last), Some(s.commits[last].id)) - }; - // Preserve outgoing traversal order after moving all outgoing edges - // from the old segment to the new segment. - for edge in edges_to_reconnect.into_iter().rev() { - self.inner.add_edge( - new_segment_sidx, - edge.target, - Edge { - src, - src_id, - dst: edge.weight.dst, - dst_id: edge.weight.dst_id, - parent_order: edge.weight.parent_order, - }, - ); - self.inner.remove_edge(edge.id); - } - - if cidx_for_new_segment == 0 { - // The top-segment is still connected with edges that think they link to a commit, so adjust them. - let edges_to_adjust: Vec<_> = self - .inner - .edges_directed(sidx, Direction::Incoming) - .map(|e| e.id()) - .collect(); - for edge_id in edges_to_adjust { - let edge = self - .inner - .edge_weight_mut(edge_id) - .expect("still present as we just saw it"); - edge.dst = None; - edge.dst_id = None; - } - } - Ok(new_segment_sidx) - } - - /// To keep it simple, the iteration will not always create perfect segment names right away so we - /// fix it in post. - /// - /// * segments are anonymous even though there is an unambiguous name for its first parent. - /// These segments sometimes are inserted to assure workspace segments don't own non-workspace commits. - /// * segments have a name, but the same name is still visible in the refs of the first commit. - /// - /// Only perform disambiguation on proxy segments (i.e. those inserted segments to prevent commit-ownership). - fn fixup_segment_names( - &mut self, - meta: &OverlayMetadata<'_, T>, - inserted_proxy_segments: &[SegmentIndex], - worktree_by_branch: &WorktreeByBranch, - ) { - let segments_with_refs_on_first_commit: Vec<_> = self - .inner - .node_indices() - .filter(|sidx| { - self[*sidx] - .commits - .first() - .is_some_and(|c| !c.refs.is_empty()) - }) - .collect(); - for sidx in segments_with_refs_on_first_commit { - let s = &mut self.inner[sidx]; - let first_commit = &mut s.commits[0]; - if let Some(srn) = &s.ref_info { - if let Some(pos) = first_commit.refs.iter().position(|rn| rn == srn) { - first_commit.refs.remove(pos); - } - } else { - match first_commit.refs.len() { - 0 => unreachable!("prefiltered"), - 1 => { - if first_commit - .refs - .first() - .is_some_and(|rn| rn.ref_name.category() == Some(Category::LocalBranch)) - { - s.ref_info = first_commit.refs.pop(); - s.metadata = meta - .branch_opt(s.ref_name().expect("just set")) - .ok() - .flatten() - .map(|md| SegmentMetadata::Branch(md.clone())); - } - } - _ => { - if !inserted_proxy_segments.contains(&sidx) { - continue; - } - let Some((rn, metadata)) = disambiguate_refs_by_branch_metadata( - first_commit.refs.iter().map(|ri| &ri.ref_name), - meta, - ) else { - continue; - }; - - s.metadata = metadata; - let ref_info = first_commit - .ref_by_name(rn.as_ref()) - .cloned() - .unwrap_or_else(|| { - crate::RefInfo::from_ref( - rn.clone(), - first_commit.id, - worktree_by_branch, - ) - }); - first_commit.refs.retain(|cri| cri.ref_name != rn); - s.ref_info = Some(ref_info); - } - } - } - } - } - - /// Find all *unique* commits (along with their owning segments) where we can look for references that are to be - /// spread out as independent branches. - /// This means these commits must be in the workspace! - fn candidates_for_independent_branches_in_workspace( - &self, - ws_sidx: SegmentIndex, - target_ref: Option, - target_commit_sidx: Option, - ws_low_bound: Option, - ws_stacks: &[crate::workspace::Stack], - repo: &OverlayRepo<'_>, - ) -> anyhow::Result> { - let mut out: Vec<_> = ws_stacks - .iter() - .filter_map(|s| { - s.base_segment_id().and_then(|sidx| { - let base_segment = &self[sidx]; - // These are naturally in the workspace. - base_segment - .commit_index_of(s.base().expect("must be set if sidx is set")) - .map(|cidx| (sidx, &base_segment.commits[cidx])) - }) - }) - .chain( - self.inner - .neighbors_directed(ws_sidx, Direction::Outgoing) - .filter_map(|s| { - // This rule means that if there is no target, we'd want to put new independent stacks - // onto segments which then are ambiguous so they get pulled out. - if target_ref.is_some() { - return None; - } - // It's a very specialized filterโ€ฆ will that lead to strange behavior later? - let segment = &self[s]; - if segment.ref_info.is_some() { - return None; - } - segment - .commits - .first() - .filter(|c| !c.refs.is_empty()) - .map(|c| (s.id(), c)) - }), - ) - .collect(); - out.sort_by_key(|t| t.1.id); - out.dedup_by_key(|t| t.1.id); - - let mut out: Vec<_> = out.into_iter().map(|t| t.0).collect(); - for extra_sidx in target_commit_sidx.into_iter().chain(ws_low_bound).dedup() { - out.extend( - self.resolve_to_unambiguously_pointed_to_commit(extra_sidx) - .and_then(|(c, sidx)| { - (!out.contains(&sidx) && c.flags.contains(CommitFlags::InWorkspace)) - .then_some(sidx) - }), - ); - } - - match target_ref { - None => { - let Some(commit_with_refs) = - self[ws_sidx].commits.first().filter(|c| !c.refs.is_empty()) - else { - return Ok(out); - }; - - // Never create anything on top of managed commits. - if crate::workspace::commit::is_managed_workspace_by_message( - commit_with_refs - .id - .attach(repo.for_attach_only()) - .object()? - .try_into_commit()? - .message_raw()?, - ) { - return Ok(out); - } - - // This means we are managed, but have lost our workspace commit, instead we - // own a commit. This really shouldn't happen. - tracing::warn!( - "Workspace segment {ws_sidx:?} is owning a non-workspace commit\ - - this shouldn't be possible" - ) - } - Some(target_sidx) => { - let target_rtb = &self[target_sidx]; - if self.is_connected_from_above(target_sidx, ws_sidx) { - out.extend( - self.resolve_to_unambiguously_pointed_to_commit(target_rtb.id) - .and_then(|(c, sidx)| { - c.flags.contains(CommitFlags::InWorkspace).then_some(sidx) - }), - ); - out.sort(); - out.dedup(); - } - } - } - Ok(out) - } - - /// Perform operations on the current workspace, or do nothing if there is `None`. - /// - /// * workspace segments are either empty, or have just one managed commit. - /// * insert empty segments as defined by the workspace that affects its downstream. - /// * put workspace connection into the order defined in the workspace metadata. - /// * set sibling segment IDs for unnamed segments that are descendants of an out-of-workspace but known segment. - fn workspace_upgrades( - &mut self, - meta: &OverlayMetadata<'_, T>, - repo: &OverlayRepo<'_>, - refs_by_id: &RefsById, - worktree_by_branch: &WorktreeByBranch, - ) -> anyhow::Result<()> { - let Some(workspace) = self.workspace_reconciliation_input()? else { - return Ok(()); - }; - let ws_sidx = workspace.id; - let ws_low_bound_in_ws_sidx = workspace.lower_bound_segment_id_in_workspace(); - let ws_stacks = workspace.stacks; - let ws_data = workspace.metadata; - let ws_target_ref = workspace.target_ref; - let ws_target_commit = workspace.target_commit; - let ws_low_bound = workspace.lower_bound_segment_id; - - // Setup independent stacks, first by looking at potential bases. - let candidates = self.candidates_for_independent_branches_in_workspace( - ws_sidx, - ws_target_ref.as_ref().map(|t| t.segment_index), - ws_target_commit.as_ref().map(|t| t.segment_index), - ws_low_bound, - &ws_stacks, - repo, - )?; - for base_sidx in candidates.iter().cloned() { - let mut seen = BTreeSet::new(); - let base_segment = &self[base_sidx]; - // Also use the segment name as part of available refs to latch on to, and make - // the segment anonymous if it actually gets used. - let base_segment_name = base_segment.ref_info.clone(); - let matching_refs_per_stack: Vec<_> = find_all_desired_stack_refs_in_commit( - &ws_data, - base_segment_name - .as_ref() - .into_iter() - .map(|ri| &ri.ref_name) - .chain( - base_segment - .commits - .first() - .map(|c| c.refs.iter()) - .into_iter() - .flatten() - .map(|ri| &ri.ref_name), - ), - (&self.inner, ws_sidx, &ws_stacks, &candidates), - ) - .collect(); - for refs_for_independent_branches in matching_refs_per_stack { - // Matching refs can be repeated, even with unsound workspace metadata that mentions them multiple times. - // Instead of catching this earlier, we handle deduplication right here where it matters. - let unique_refs_for_independent_branches: Vec<_> = refs_for_independent_branches - .into_iter() - .filter_map(|rn| seen.insert(rn.clone()).then_some(rn)) - .collect(); - if unique_refs_for_independent_branches.is_empty() { - continue; - } - let edges_connecting_base_with_ws_tip: Vec = self - .inner - .edges_connecting(ws_sidx, base_sidx) - .map(Into::into) - .collect(); - create_independent_segments( - self, - ws_sidx, - base_sidx, - unique_refs_for_independent_branches, - meta, - worktree_by_branch, - )?; - for edge in edges_connecting_base_with_ws_tip { - self.inner.remove_edge(edge.id); - } - } - } - - // Setup dependent stacks based on searching refs on existing workspace commits. - // Note that we can still source names from previously used stacks just to be able to capture more - // of the original intent, despite the graph having changed. This works because in the end, we are consuming - // refs on commits that can't be reused once they have been moved into their own segment. - let mut segments_to_possibly_delete = Vec::new(); - for stack in &ws_stacks { - let mut last_created_segment = None; - for ws_segment_sidx in stack - .segments - .iter() - .flat_map(|segment| segment.commits_by_segment.iter().map(|t| t.0)) - { - // Find all commit-refs which are mentioned in ws_data.stacks, for simplicity in any stack that matches (for now). - // Stacks shouldn't be so different that they don't match reality anymore and each mutation has to re-set them to - // match reality. - let mut current_above = ws_segment_sidx; - let mut truncate_commits_from = None; - for commit_idx in 0..self[ws_segment_sidx].commits.len() { - let commit = &self[ws_segment_sidx].commits[commit_idx]; - let has_inserted_segment_above = current_above != ws_segment_sidx; - let Some(refs_for_dependent_branches) = - find_all_desired_stack_refs_in_commit_for_dependent_branches( - &ws_data, - commit.ref_name_iter(), - None, - ) - .next() - else { - // Now we have to assign this uninteresting commit to the last created segment, if there was one. - if has_inserted_segment_above { - self.push_commit_and_reconnect_outgoing( - commit.clone(), - current_above, - (ws_segment_sidx, commit_idx), - ); - } - continue; - }; - - // In ws-stack segment order, map all the indices from top to bottom - let new_above = maybe_create_multiple_segments( - self, - current_above, - ws_segment_sidx, - Some(commit_idx), - refs_for_dependent_branches, - meta, - worktree_by_branch, - )?; - current_above = new_above; - truncate_commits_from.get_or_insert(commit_idx); - } - if let Some(truncate_from) = truncate_commits_from { - let segment = &mut self[ws_segment_sidx]; - // Keep only the commits that weren't reassigned to other segments. - segment.commits.truncate(truncate_from); - segments_to_possibly_delete.push(ws_segment_sidx); - } - last_created_segment = Some(current_above); - } - - if let Some((last_segment_id, base_sidx, commit)) = - stack.segments.last().and_then(|s| { - let base_sidx = s.base_segment_id?; - let c = self.node_weight(base_sidx)?.commits.first()?; - Some((last_created_segment.unwrap_or(s.id), base_sidx, c)) - }) - { - let Some(refs_for_dependent_branches) = - find_all_desired_stack_refs_in_commit_for_dependent_branches( - &ws_data, - self[base_sidx] - .ref_info - .as_ref() - .map(|ri| &ri.ref_name) - .filter(|_| !commit.refs.is_empty()) - .into_iter() - .chain(commit.ref_name_iter()), - Some(stack), - ) - .next() - else { - continue; - }; - - let edges_from_segment_above = collect_edges_at_commit_in_traversal_order( - self, - ( - last_segment_id, - self[last_segment_id].commits.len().checked_sub(1), - ), - Direction::Outgoing, - ); - let new_sidx_above_base_sidx = maybe_create_multiple_segments( - self, - last_segment_id, - base_sidx, - None, - refs_for_dependent_branches.clone(), - meta, - worktree_by_branch, - )?; - - // As we didn't allow the previous function to deal with the commit, we do it. - let s = &mut self[base_sidx]; - s.commits - .first_mut() - .expect("we know there is one already") - .refs - .retain(|ri| !refs_for_dependent_branches.contains(&ri.ref_name)); - s.ref_info - .take_if(|ri| refs_for_dependent_branches.contains(&ri.ref_name)); - if s.ref_info.is_none() { - s.metadata = None; - if let Some(remote_sidx) = s.remote_tracking_branch_segment_id.take() { - self[remote_sidx].sibling_segment_id = None; - } - } - - let s = &mut self[base_sidx]; - if let Some(first_commit) = s.commits.first_mut().filter(|c| !c.refs.is_empty()) - && let Some((name, md)) = disambiguate_refs_by_branch_metadata( - first_commit.refs.iter().map(|ri| &ri.ref_name), - meta, - ) - { - let ref_info = first_commit - .ref_by_name(name.as_ref()) - .cloned() - .unwrap_or_else(|| { - crate::RefInfo::from_ref(name.clone(), None, worktree_by_branch) - }); - first_commit.refs.retain(|ri| ri.ref_name != name); - s.ref_info = Some(ref_info); - s.metadata = md; - } - reconnect_outgoing_edges( - self, - edges_from_segment_above, - (new_sidx_above_base_sidx, None), - ); - } - } - - for sidx in segments_to_possibly_delete { - delete_anon_if_empty_and_reconnect(self, sidx); - } - - // Redo workspace outgoing connections according to desired stack order. - let mut edges_pointing_to_named_segment = self - .inner - .edges_directed(ws_sidx, Direction::Outgoing) - .map(|e| { - let rn = self[e.target()].ref_info.clone(); - (e.id(), e.target(), rn) - }) - .collect::>(); - - let edges_original_order: Vec<_> = edges_pointing_to_named_segment - .iter() - .map(|(_e, sidx, _rn)| *sidx) - .collect(); - edges_pointing_to_named_segment.sort_by_key(|(_e, sidx, ri)| { - let res = ws_data.stacks.iter().position(|s| { - s.is_in_workspace() - && s.branches - .first() - .is_some_and(|b| Some(&b.ref_name) == ri.as_ref().map(|ri| &ri.ref_name)) - }); - // This makes it so that edges that weren't mentioned in workspace metadata - // retain their relative order, with first-come-first-serve semantics. - // The expected case is that each segment is defined. - res.or_else(|| { - edges_original_order - .iter() - .position(|sidx_for_order| sidx_for_order == sidx) - }) - }); - - // Re-add in reverse because petgraph traverses newest edges first. - for (eid, target_sidx, _) in edges_pointing_to_named_segment.into_iter().rev() { - let weight = self - .inner - .remove_edge(eid) - .expect("we found the edge before"); - // Reconnect according to the new order. - self.inner.add_edge(ws_sidx, target_sidx, weight); - } - - // Setup sibling IDs for all unnamed segments with a known segment ref in its future. - let unique_ws_segment_ids: BTreeSet = ws_stacks - .iter() - .flat_map(|s| { - s.segments - .iter() - .flat_map(|s| s.commits_by_segment.iter().map(|(sidx, _)| *sidx)) - }) - .collect(); - let mut segment_visit_scratch = SegmentVisitScratch::new(self); - for &sidx in &unique_ws_segment_ids { - // The workspace might be stale by now as we delete empty segments. - // Thus, be careful, and ignore non-existing ones - after all our workspace - // is temporary, nothing to worry about. - let Some(s) = self.inner.node_weight(sidx) else { - continue; - }; - if s.ref_info.is_some() || s.sibling_segment_id.is_some() { - continue; - } - - let num_incoming = self - .inner - .neighbors_directed(sidx, Direction::Incoming) - .count(); - let single_incoming_connection = num_incoming < 2; - if single_incoming_connection { - continue; - } - - let mut named_segment_id = None; - segment_visit_scratch.visit_excluding_start_until( - self, - sidx, - Direction::Incoming, - |s| { - let prune = true; - if named_segment_id.is_some() - || s.commits - .first() - .is_some_and(|c| c.flags.contains(CommitFlags::InWorkspace)) - { - return prune; - } - - s.ref_info.as_ref().is_some_and(|ri| { - let is_known_to_workspace = - ws_data.contains_ref(ri.ref_name.as_ref(), AppliedAndUnapplied); - if is_known_to_workspace { - named_segment_id = Some(s.id); - } - is_known_to_workspace - }) - }, - ); - if let Some(named_sid) = named_segment_id - && self[sidx].sibling_segment_id.is_none() - { - // Don't set sibling if the named segment is already known to the workspace - // by direct connection. However, if the named segment is *not* a direct - // workspace child and there are no further workspace segments below this - // anonymous one, the sibling is the only way to identify the stack. - let segment_name = self[named_sid] - .ref_info - .as_ref() - .expect("BUG: named segment must have name") - .ref_name - .as_ref(); - let is_stack_tip = ws_data.stack_names(Applied).any(|sn| sn == segment_name); - if !is_stack_tip { - self[sidx].sibling_segment_id = Some(named_sid); - } else { - let named_is_direct_parent = self - .inner - .neighbors_directed(sidx, Direction::Incoming) - .any(|n| n == named_sid); - let named_direct_parent_has_outside_commits = named_is_direct_parent && { - let mut has_outside_commits = false; - segment_visit_scratch.visit_including_start_until( - self, - named_sid, - Direction::Outgoing, - |segment| { - let prune = true; - if segment - .commits - .iter() - .any(|c| c.flags.contains(CommitFlags::InWorkspace)) - { - return prune; - } - has_outside_commits |= !segment.commits.is_empty(); - has_outside_commits - }, - ); - has_outside_commits - }; - let named_is_direct_ws_child = self - .inner - .neighbors_directed(ws_sidx, Direction::Outgoing) - .any(|n| n == named_sid); - let has_ws_segments_below = self - .inner - .neighbors_directed(sidx, Direction::Outgoing) - .any(|n| unique_ws_segment_ids.contains(&n)); - if named_direct_parent_has_outside_commits - || (!named_is_direct_ws_child && !has_ws_segments_below) - { - self[sidx].sibling_segment_id = Some(named_sid); - } - } - } - } - - // The named-segment check is needed as we don't want to double-split unnamed segments. - // What this really does is to pass ownership of the base commit from a named segment to an unnamed one, - // as all algorithms kind of rely on it. - // So if this ever becomes a problem, we can also try to adjust said algorithms downstream. - if let Some(low_bound_segment_id) = ws_low_bound_in_ws_sidx - && self[low_bound_segment_id].ref_info.is_some() - { - self.split_segment( - low_bound_segment_id, - 0, - None, - Some(refs_by_id), - meta, - worktree_by_branch, - )?; - } - Ok(()) - } - - /// Name ambiguous segments if they are reachable by a remote-tracking branch - /// and the first commit has the unambiguous matching local tracking branch. - /// Also link all paired local and remote segments in both directions. - /// - /// Additionally, restore possibly broken linkage to their siblings - /// (from remote to local tracking branch), as this connection might have been destroyed by - /// the insertion of empty segments. Again, instead of making that smarter, we fix it up here - /// because it's simpler. - /// - /// Target remote refs need one extra repair. Their local tracking branch may - /// point at the same commit while another branch segment owns that commit, - /// leaving the local branch only as a commit ref. In that case, create an - /// empty local tracking segment that points at the owning segment, remove the - /// duplicate commit ref, and set the remote/local sibling links. This is - /// restricted to named target-remote traversal tips so ordinary remote refs - /// keep their historical presentation unless an earlier disambiguation step - /// already paired them. - fn improve_remote_segments( - &mut self, - repo: &OverlayRepo<'_>, - meta: &OverlayMetadata<'_, impl RefMetadata>, - symbolic_remote_names: &[String], - configured_remote_tracking_branches: &BTreeSet, - worktree_by_branch: &WorktreeByBranch, - ) -> 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::< - SegmentIndex, - Vec<(gix::refs::FullName, gix::refs::FullName, SegmentIndex)>, - >::new(); - - let mut remote_sidx_by_ref_name = BTreeMap::new(); - for (remote_sidx, remote_ref_name) in self.inner.node_indices().filter_map(|sidx| { - self[sidx] - .ref_info - .as_ref() - .map(|ri| &ri.ref_name) - .filter(|rn| rn.category() == Some(Category::RemoteBranch)) - .map(|rn| (sidx, rn)) - }) { - remote_sidx_by_ref_name.insert(remote_ref_name.clone(), remote_sidx); - let start_idx = self[remote_sidx].commits.first().map(|_| 0); - 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]; - if segment.ref_info.is_some() { - // Assume simple linear histories - otherwise this could abort too early, and - // we'd need a complex traversal - not now. - break; - } - - if segment.commits.is_empty() { - // skip over empty anonymous buckets, even though these shouldn't exist, ever. - tracing::warn!( - "Skipped segment {sidx} which was anonymous and empty", - sidx = sidx.index() - ); - continue; - } else if segment.commits[commit_range] - .iter() - .all(|c| c.flags.contains(CommitFlags::NotInRemote)) - { - // 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(|ri| { - remotes::lookup_remote_tracking_branch_or_deduce_it( - repo, - ri.ref_name.as_ref(), - symbolic_remote_names, - configured_remote_tracking_branches, - ) - .ok() - .flatten() - .and_then(|rrn| (&rrn == remote_ref_name).then_some(ri.ref_name.clone())) - }) { - remotes_by_segment_map.entry(sidx).or_default().push(( - local_tracking_branch, - remote_ref_name.to_owned(), - remote_sidx, - )); - } - break; - } - // Assume that the segment is fully remote. - continue; - } - } - - for (anon_sidx, mut disambiguated_name) in remotes_by_segment_map - .into_iter() - .filter(|(_, candidates)| candidates.len() == 1) - { - let s = &mut self[anon_sidx]; - let segment_id = s.id.index(); - let no_commits = || { - format!( - "BUG: remote-disambiguated anonymous segment {segment_id} should contain commits" - ) - }; - let (local, remote, remote_sidx) = - disambiguated_name.pop().expect("one item as checked above"); - let commit_id = s.commits.first().with_context(no_commits)?.id; - s.ref_info = Some(crate::RefInfo::from_ref( - local, - commit_id, - worktree_by_branch, - )); - s.remote_tracking_ref_name = Some(remote); - s.remote_tracking_branch_segment_id = Some(remote_sidx); - let rn = s.ref_info.as_ref().expect("just set it"); - s.commits - .first_mut() - .with_context(no_commits)? - .refs - .retain(|crn| crn != rn); - // Assure the remote is also paired up! - self[remote_sidx].sibling_segment_id = Some(s.id); - } - - // NOTE: setting this directly at iteration time isn't great as the post-processing then - // also has to deal with these implicit connections. So it's best to redo them in the end. - let mut links_from_remote_to_local = Vec::new(); - for segment in self.inner.node_weights_mut() { - if segment.remote_tracking_ref_name.is_some() { - continue; - }; - let Some(ref_name) = segment.ref_info.as_ref().map(|ri| &ri.ref_name) 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, - )?; - - if let Some(remote_sidx) = segment - .remote_tracking_ref_name - .as_ref() - .and_then(|rn| remote_sidx_by_ref_name.remove(rn.as_ref())) - { - segment.remote_tracking_branch_segment_id = Some(remote_sidx); - links_from_remote_to_local.push((remote_sidx, segment.id)); - } - } - let workspace_target_refs: BTreeSet<_> = self - .traversal_tips - .iter() - .filter(|tip| matches!(tip.role, TipRole::TargetRemote)) - .filter_map(|tip| tip.ref_name.clone()) - .collect(); - for (remote_ref_name, remote_sidx) in remote_sidx_by_ref_name { - if !workspace_target_refs.contains(&remote_ref_name) { - continue; - } - let Some(local_sidx) = self.ensure_local_tracking_segment_for_remote( - repo, - meta, - remote_ref_name, - remote_sidx, - worktree_by_branch, - )? - else { - continue; - }; - links_from_remote_to_local.push((remote_sidx, local_sidx)); - } - for (remote_sidx, local_sidx) in links_from_remote_to_local { - self[remote_sidx].sibling_segment_id = Some(local_sidx); - - // If both remote and local point to the same commit, make sure that the remote points to the local segment. - if let Some(( - (_remote_commit, _owner_of_commit_same_as_local), - (_local_commit, owner_of_commit_same_as_remote), - )) = self - .resolve_to_unambiguously_pointed_to_commit(remote_sidx) - .zip(self.resolve_to_unambiguously_pointed_to_commit(local_sidx)) - .filter(|((a, a_sidx), (b, b_sidx))| a.id == b.id && a_sidx == b_sidx) - { - let outgoing: Vec<_> = self - .inner - .edges_directed(remote_sidx, Direction::Outgoing) - .map(EdgeOwned::from) - .collect(); - let remote_is_connected_to_local = - outgoing.iter().any(|e| e.target.id() == local_sidx); - if !remote_is_connected_to_local - && let Some(edge) = outgoing.iter().find(|e| { - outgoing.len() == 1 || e.target.id() == owner_of_commit_same_as_remote - }) - { - self.inner.remove_edge(edge.id); - self.inner.add_edge( - remote_sidx, - local_sidx, - edge.weight - .adjusted_for(remote_sidx, local_sidx, &self.inner), - ); - } - } - } - Ok(()) - } - - /// Ensure `remote_sidx` can reach the local branch configured to track - /// `remote_ref_name`. - /// - /// Most local/remote tracking pairs are established earlier in this pass: - /// either an anonymous local segment is disambiguated by walking down from - /// the remote, or a named local segment already advertises - /// `remote_tracking_ref_name`. The remaining target-ref edge case is a - /// missing local-side segment: - /// - /// * `refs/remotes/origin/main` is a target segment. - /// * `refs/heads/main` is configured as its local tracking branch. - /// * The local branch's tip commit is already owned by another segment, for - /// example because branch metadata disambiguated a shared tip. - /// * `main` is therefore only a ref on that owning commit, so the target - /// segment has no `sibling_segment_id`. - /// - /// If a segment named after the local tracking branch already exists, return - /// it as-is. Otherwise, create an empty local tracking segment named after - /// the configured local branch and point it at the segment/commit that owns - /// the local branch tip. The local and remote tips do not have to be the - /// same commit; the sibling relationship represents the configured tracking - /// pair, while the new empty segment preserves where the local ref points in - /// the graph. The caller records the reverse `sibling_segment_id` link on - /// the remote segment afterwards. - /// - /// Returning `None` means the graph should keep its current presentation. - /// That happens when there is no configured local tracking branch, the local - /// ref no longer exists, its tip was not traversed, or the local tip is not - /// the first commit of the segment that owns it. The last case keeps this - /// synthetic empty segment from pointing into the middle of an existing - /// segment. - fn ensure_local_tracking_segment_for_remote( - &mut self, - repo: &OverlayRepo<'_>, - meta: &OverlayMetadata<'_, T>, - remote_ref_name: gix::refs::FullName, - remote_sidx: SegmentIndex, - worktree_by_branch: &WorktreeByBranch, - ) -> anyhow::Result> { - let Some((local_ref_name, _remote)) = - repo.upstream_branch_and_remote_for_tracking_branch(remote_ref_name.as_ref())? - else { - return Ok(None); - }; - - let Some(local_tip) = repo - .try_find_reference(local_ref_name.as_ref())? - .map(|mut r| r.peel_to_id().map(|id| id.detach())) - .transpose()? - else { - return Ok(None); - }; - - let (sidx_with_local_ref_name, commit_owner) = - self.segment_by_ref_name_or_commit_id(local_ref_name.as_ref(), local_tip); - if let Some(local_sidx) = sidx_with_local_ref_name { - return Ok(Some(local_sidx)); - } - let Some((owner_sidx, owner_cidx)) = commit_owner else { - return Ok(None); - }; - if owner_cidx != 0 { - return Ok(None); - } - - let local_segment = crate::Segment { - metadata: meta - .branch_opt(local_ref_name.as_ref())? - .map(SegmentMetadata::Branch), - ref_info: Some(crate::RefInfo::from_ref( - local_ref_name.clone(), - local_tip, - worktree_by_branch, - )), - remote_tracking_ref_name: Some(remote_ref_name), - remote_tracking_branch_segment_id: Some(remote_sidx), - ..Default::default() - }; - let local_sidx = self.insert_segment(local_segment); - self.connect_segments_with_ids( - local_sidx, - None, - None, - owner_sidx, - Some(owner_cidx), - Some(local_tip), - 0, - ); - self[owner_sidx].commits[owner_cidx] - .refs - .retain(|ri| ri.ref_name != local_ref_name); - Ok(Some(local_sidx)) - } - - fn segment_by_ref_name_or_commit_id( - &self, - ref_name: &gix::refs::FullNameRef, - commit_id: gix::ObjectId, - ) -> (Option, Option<(SegmentIndex, CommitIndex)>) { - for segment in self.inner.node_weights() { - if segment.ref_name().is_some_and(|rn| rn == ref_name) { - return (Some(segment.id), None); - } - if let Some(commit_idx) = segment.commit_index_of(commit_id) { - return (None, Some((segment.id, commit_idx))); - } - } - (None, None) - } - - fn push_commit_and_reconnect_outgoing( - &mut self, - commit: Commit, - current_above: SegmentIndex, - (ws_segment_sidx, commit_idx): (SegmentIndex, CommitIndex), - ) { - let commit_id = commit.id; - self[current_above].commits.push(commit); - reconnect_outgoing( - &mut self.inner, - (ws_segment_sidx, commit_idx), - (current_above, commit_id), - ); - } - - // Fill in generation numbers by walking down the graph topologically. - // This is called at the end of post-processing to ensure generations are correct - // after all segment insertions and edge rewiring. - fn compute_generation_numbers(&mut self) { - let mut topo = petgraph::visit::Topo::new(&self.inner); - while let Some(sidx) = topo.next(&self.inner) { - let max_gen_of_incoming = self - .inner - .neighbors_directed(sidx, petgraph::Direction::Incoming) - .map(|sidx| self[sidx].generation + 1) - .max() - .unwrap_or(0); - self[sidx].generation = max_gen_of_incoming; - } - } - - /// Returns `true` if `below_sidx` is connected to `above_sidx` from above, so `above_sidx` has an - /// outgoing connection to `below_sidx`. - fn is_connected_from_above(&self, below_sidx: SegmentIndex, above_sidx: SegmentIndex) -> bool { - self.edges_directed(below_sidx, Direction::Incoming) - .any(|e| e.source() == above_sidx) - } - - /// In ad-hoc/single-branch mode, use persisted GitButler-created branch - /// ordering to split multiple same-tip refs into empty stack segments. - fn ad_hoc_branch_stack_upgrades( - &mut self, - repo: &OverlayRepo<'_>, - meta: &OverlayMetadata<'_, T>, - worktree_by_branch: &WorktreeByBranch, - ) -> anyhow::Result<()> { - let Some(entrypoint_ref) = self.entrypoint_ref.clone() else { - return Ok(()); - }; - if entrypoint_ref.category() != Some(Category::LocalBranch) { - return Ok(()); - } - let Some((_entrypoint_sidx, _entrypoint)) = self.entrypoint else { - return Ok(()); - }; - let Some(branch_order) = meta.branch_stack_order(entrypoint_ref.as_ref())? else { - return Ok(()); - }; - let mut existing_ordered_refs = Vec::new(); - for branch in branch_order { - if branch.category() != Some(Category::LocalBranch) { - continue; - } - let Some(mut reference) = - repo.try_find_reference(branch.as_ref()).with_context(|| { - format!( - "failed to find ordered ad-hoc branch '{}'", - branch.shorten() - ) - })? - else { - continue; - }; - let commit_id = reference - .peel_to_id() - .with_context(|| { - format!( - "failed to peel ordered ad-hoc branch '{}'", - branch.shorten() - ) - })? - .detach(); - existing_ordered_refs.push((branch, commit_id)); - } - if existing_ordered_refs.len() < 2 { - return Ok(()); - } - - // Projection needs the complete persisted order to keep walking across commit-owning - // boundaries. Same-tip groups below are only the units that need graph reconstruction. - self.ad_hoc_branch_stack_orders.push( - existing_ordered_refs - .iter() - .map(|(branch, _)| branch.clone()) - .collect(), - ); - - // A persisted stack can cross commit-owning branch boundaries. Rebuild every contiguous - // same-tip run independently so an empty run at the top isn't compared to the older tip - // of the stack's bottom branch. - let mut same_tip_groups: Vec<(ObjectId, Vec)> = Vec::new(); - for (branch, commit_id) in existing_ordered_refs { - if let Some((group_commit_id, branches)) = same_tip_groups.last_mut() - && *group_commit_id == commit_id - { - branches.push(branch); - } else { - same_tip_groups.push((commit_id, vec![branch])); - } - } - - for (commit_id, matching_refs) in same_tip_groups { - if matching_refs.len() < 2 { - continue; - } - let bottom_ref = matching_refs - .last() - .context("BUG: same-tip branch group cannot be empty")?; - let containing_commit = self.node_weights().find_map(|segment| { - segment - .commit_index_of(commit_id) - .filter(|commit_idx| *commit_idx > 0) - .map(|commit_idx| (segment.id, commit_idx)) - }); - let bottom_segment_id = if let Some(segment_id) = - ad_hoc_order_bottom_segment_id(self, bottom_ref.as_ref(), commit_id) - { - segment_id - } else if let Some((segment_id, commit_idx)) = containing_commit { - self.split_segment( - segment_id, - commit_idx, - Some(bottom_ref.clone()), - None, - meta, - worktree_by_branch, - )? - } else { - continue; - }; - - rebuild_same_tip_segment_chain_by_branch_order( - self, - bottom_segment_id, - matching_refs, - entrypoint_ref.as_ref(), - meta, - worktree_by_branch, - )?; - } - Ok(()) - } -} - -fn ad_hoc_order_bottom_segment_id( - graph: &Graph, - bottom_ref: &gix::refs::FullNameRef, - bottom_commit_id: gix::ObjectId, -) -> Option { - graph - .node_weights() - .find(|segment| { - segment - .ref_name() - .is_some_and(|ref_name| ref_name == bottom_ref) - }) - .or_else(|| { - graph - .node_weights() - .find(|segment| segment.tip() == Some(bottom_commit_id)) - }) - .map(|segment| segment.id) -} - -/// Rebuild a same-tip ref bundle into the persisted ad-hoc branch order. -/// -/// The refs all point at the same commit, so only the bottom ordered branch -/// owns that commit. Branches above it become explicit empty segments, allowing -/// single-branch mode to represent "top is empty above bottom" without managed -/// workspace metadata. -fn rebuild_same_tip_segment_chain_by_branch_order( - graph: &mut Graph, - bottom_segment_id: SegmentIndex, - matching_refs: Vec, - entrypoint_ref: &gix::refs::FullNameRef, - meta: &OverlayMetadata<'_, T>, - worktree_by_branch: &WorktreeByBranch, -) -> anyhow::Result<()> { - let Some((bottom_ref, empty_refs)) = matching_refs.split_last() else { - return Ok(()); - }; - if empty_refs.is_empty() { - return Ok(()); - } - - let mut empty_segment_by_ref = BTreeMap::new(); - for segment in graph.node_weights() { - if segment.commits.is_empty() - && let Some(ref_name) = segment.ref_name() - && empty_refs - .iter() - .any(|empty_ref| empty_ref.as_ref() == ref_name) - { - empty_segment_by_ref.insert(ref_name.to_owned(), segment.id); - } - } - - let bottom_commit_id = graph[bottom_segment_id] - .commits - .first() - .map(|commit| commit.id); - let mut bottom_segment = branch_segment_from_name_and_meta( - Some((bottom_ref.clone(), None)), - meta, - None, - worktree_by_branch, - )?; - if let Some(ref_info) = bottom_segment.ref_info.as_mut() { - ref_info.commit_id = bottom_commit_id; - } - graph[bottom_segment_id].ref_info = bottom_segment.ref_info; - graph[bottom_segment_id].metadata = bottom_segment.metadata; - if let Some(commit) = graph[bottom_segment_id].commits.first_mut() { - commit.refs.retain(|ri| { - !matching_refs - .iter() - .any(|ref_name| ref_name == &ri.ref_name) - }); - } - - let mut ordered_segment_ids = Vec::new(); - let mut entrypoint_segment_id = None; - for ref_name in empty_refs { - let segment_id = if let Some(segment_id) = empty_segment_by_ref.get(ref_name.as_ref()) { - *segment_id - } else { - let new_segment = branch_segment_from_name_and_meta( - Some((ref_name.clone(), None)), - meta, - None, - worktree_by_branch, - )?; - graph.insert_segment(new_segment) - }; - if ref_name.as_ref() == entrypoint_ref { - entrypoint_segment_id = Some(segment_id); - } - ordered_segment_ids.push(segment_id); - } - ordered_segment_ids.push(bottom_segment_id); - if bottom_ref.as_ref() == entrypoint_ref { - entrypoint_segment_id = Some(bottom_segment_id); - } - - let involved_segments = ordered_segment_ids.iter().copied().collect::>(); - let incoming_edges = involved_segments - .iter() - .flat_map(|segment_id| { - graph - .inner - .edges_directed(*segment_id, Direction::Incoming) - .map(EdgeOwned::from) - .collect::>() - }) - .filter(|edge| !involved_segments.contains(&edge.source)) - .collect::>(); - let incoming_edge_ids_to_remove = involved_segments - .iter() - .flat_map(|segment_id| { - graph - .inner - .edges_directed(*segment_id, Direction::Incoming) - .map(|edge| edge.id()) - .collect::>() - }) - .collect::>(); - let outgoing_edge_ids_to_remove = involved_segments - .iter() - .flat_map(|segment_id| { - graph - .inner - .edges_directed(*segment_id, Direction::Outgoing) - .filter(|edge| { - *segment_id != bottom_segment_id || involved_segments.contains(&edge.target()) - }) - .map(|edge| edge.id()) - .collect::>() - }) - .collect::>(); - let edge_ids_to_remove = incoming_edge_ids_to_remove - .into_iter() - .chain(outgoing_edge_ids_to_remove) - .collect::>(); - for edge_id in edge_ids_to_remove { - graph.inner.remove_edge(edge_id); - } - - for pair in ordered_segment_ids.windows(2) { - let [above, below] = pair else { - continue; - }; - let below_commit_index = (*below == bottom_segment_id).then_some(0); - graph.connect_segments(*above, None, *below, below_commit_index); - } - - let top_segment_id = ordered_segment_ids - .first() - .copied() - .context("BUG: empty refs should create a top segment")?; - for edge in incoming_edges.into_iter().rev() { - graph.inner.add_edge( - edge.source, - top_segment_id, - Edge { - src: edge.weight.src, - src_id: edge.weight.src_id, - dst: None, - dst_id: None, - parent_order: edge.weight.parent_order, - }, - ); - } - - if graph - .entrypoint - .as_ref() - .is_some_and(|(entrypoint_segment_id, _)| involved_segments.contains(entrypoint_segment_id)) - { - let entrypoint_commit = bottom_commit_id - .map(EntryPointCommit::AtCommit) - .unwrap_or(EntryPointCommit::Unborn); - graph.entrypoint = Some(( - entrypoint_segment_id - .context("BUG: split branch order should retain the entrypoint ref")?, - entrypoint_commit, - )); - } - Ok(()) -} - -/// Search `ws_data` for all matching names in `commit_refs` and return them, once per stack. -/// If `only_in_stack` is set, it will only search the stack in `ws_data` that either matches its stack-id or -/// its tip name, to avoid matching names that are not meant to be used in `only_in_stack`. -fn find_all_desired_stack_refs_in_commit_for_dependent_branches<'a>( - ws_data: &'a ref_metadata::Workspace, - commit_refs: impl Iterator + Clone + 'a, - only_in_stack: Option<&'a crate::workspace::Stack>, -) -> impl Iterator> + 'a { - ws_data.stacks(Applied).filter_map(move |stack| { - if only_in_stack.is_some_and(|limit_to| { - limit_to.id != Some(stack.id) && limit_to.ref_name() != stack.name() - }) { - return None; - } - let matching_refs: Vec<_> = stack - .branches - .iter() - .filter_map(|s| commit_refs.clone().find(|rn| *rn == &s.ref_name).cloned()) - .collect(); - if matching_refs.is_empty() { - return None; - } - Some(matching_refs) - }) -} - -fn find_all_desired_stack_refs_in_commit<'a>( - ws_data: &'a ref_metadata::Workspace, - commit_refs: impl Iterator + Clone + 'a, - graph_and_ws_idx_and_candidates: ( - &'a PetGraph, - SegmentIndex, - &'a [crate::workspace::Stack], - &'a [SegmentIndex], - ), -) -> impl Iterator> + 'a { - let (graph, ws_idx, ws_stacks, candidates) = graph_and_ws_idx_and_candidates; - ws_data.stacks(Applied).filter_map(move |stack| { - if ws_stacks - .iter() - .filter(|s| !s.segments.iter().any(|s| candidates.contains(&s.id))) - .any(|existing_stack| existing_stack.id == Some(stack.id)) - { - return None; - } - let matching_refs: Vec<_> = stack - .branches - .iter() - .filter_map(|s| commit_refs.clone().find(|rn| *rn == &s.ref_name).cloned()) - .collect(); - if matching_refs.is_empty() { - return None; - } - - // We match any part of a stack above, so have to assure we don't recreate - // part of an existing stack as new stack. - let is_used_in_existing_stack = stack.branches.first().is_some_and(|top_segment_name| { - graph - .neighbors_directed(ws_idx, Direction::Outgoing) - .filter(|sidx| !candidates.contains(sidx)) - .any(|stack_sidx| { - graph[stack_sidx].ref_name() == Some(top_segment_name.ref_name.as_ref()) - }) - }); - if is_used_in_existing_stack { - return None; - } - Some(matching_refs) - }) -} - -/// **Warning**: this can make workspace stacks stale, i.e. let them refer to non-existing segments. -/// all accesses from hereon must be done with care. On the other hand, we can ignore -/// that as our workspace is just temporary. -fn delete_anon_if_empty_and_reconnect(graph: &mut Graph, sidx: SegmentIndex) { - let segment = &graph[sidx]; - let may_delete = segment.commits.is_empty() && segment.ref_info.is_none(); - if !may_delete { - return; - } - - let mut outgoing = graph.inner.edges_directed(sidx, Direction::Outgoing); - let Some(first_outgoing) = outgoing.next() else { - return; - }; - - if outgoing.next().is_some() { - return; - } - - tracing::debug!( - ?sidx, - "Deleting seemingly isolated and now completely unused segment" - ); - // Reconnect - let new_target = first_outgoing.target(); - let incoming: Vec<_> = graph - .inner - .edges_directed(sidx, Direction::Incoming) - .map(EdgeOwned::from) - .collect(); - let (target_commit_id, target_commit_idx) = graph[new_target] - .commits - .first() - .map(|c| (Some(c.id), Some(0))) - .unwrap_or_default(); - // Preserve incoming traversal order after moving the edges to `new_target`. - for edge in incoming.iter().rev() { - graph.inner.add_edge( - edge.source, - new_target, - Edge { - src: edge.weight.src, - src_id: edge.weight.src_id, - dst: target_commit_idx, - dst_id: target_commit_id, - parent_order: edge.weight.parent_order, - }, - ); - } - graph.inner.remove_node(sidx); - - if let Some(ep_sidx) = graph - .entrypoint - .as_mut() - .map(|t| &mut t.0) - .filter(|ep_sidx| **ep_sidx == sidx) - { - *ep_sidx = new_target; - } -} - -/// Create as many new segments as refs in `matching_refs`, connect them to each other in order, and finally connect them -/// with `above_idx` and `below_idx` to integrate them into the workspace that is bounded by these segments. -fn create_independent_segments( - graph: &mut Graph, - above_idx: SegmentIndex, - below_idx: SegmentIndex, - matching_refs: Vec, - meta: &OverlayMetadata<'_, T>, - worktree_by_branch: &WorktreeByBranch, -) -> anyhow::Result<()> { - assert!(!matching_refs.is_empty()); - - let mut above = above_idx; - let mut new_refs = graph[below_idx].commits[0].refs.clone(); - for ref_name in matching_refs { - let new_segment = branch_segment_from_name_and_meta( - Some((ref_name.clone(), None)), - meta, - None, - worktree_by_branch, - )?; - let new_segment_sidx = graph.connect_new_segment( - above, - graph[above].last_commit_index(), - new_segment, - None, - None, - 0, - ); - above = new_segment_sidx; - - match new_refs.iter().position(|ri| ri.ref_name == ref_name) { - None => { - let s = &mut graph[below_idx]; - if s.ref_name() != Some(ref_name.as_ref()) { - bail!( - "BUG: ref-names must either be present in the first commit, or be the segment name: below_idx = {below_idx:?}, below.ref_name = {below_name:?}, above.ref_name = {above_name}", - below_name = s.ref_name().map(|rn| rn.as_bstr()), - above_name = ref_name.as_bstr() - ) - } - s.ref_info = None; - s.metadata = None; - let sibling = s.sibling_segment_id.take(); - let remote = s.remote_tracking_branch_segment_id.take(); - graph[new_segment_sidx].sibling_segment_id = sibling; - graph[new_segment_sidx].remote_tracking_branch_segment_id = remote; - - if let Some((ep_sidx, _)) = graph.entrypoint.as_mut() - && *ep_sidx == below_idx - { - *ep_sidx = new_segment_sidx; - } - } - Some(pos) => { - new_refs.remove(pos); - } - } - } - graph.connect_segments(above, None, below_idx, Some(0)); - if let Some(first_comit) = graph[below_idx].commits.first_mut() { - first_comit.refs = new_refs; - } - Ok(()) -} - -/// Maybe create a new stack from `N` (where `N` > 1) 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_below` is the segment to use `commit_idx` on to get its data, and assumed below `above_idx`. -/// We also use this information to re-link segments (vaguely). -/// If `commit_idx` is `None` (hack), don't add a commit at all, leave the segments empty. -/// Return the index of the bottom-most created segment. -/// There may be any amount of new segments above the `bottom_segment_index`. -/// Note that the Segment at `bottom_segment_index` will own `commit`. -/// Also note that we reconnect commit-by-commit, so the outer processing has to do that. -/// Note that it may avoid creating a new segment. -fn maybe_create_multiple_segments( - graph: &mut Graph, - mut above_idx: SegmentIndex, - commit_parent_below: SegmentIndex, - commit_idx: Option, - matching_refs: Vec, - meta: &OverlayMetadata<'_, T>, - worktree_by_branch: &WorktreeByBranch, -) -> anyhow::Result { - assert!( - !matching_refs.is_empty(), - "BUG: We really expect to create new segments here" - ); - let commit = commit_idx.map(|cidx| &graph[commit_parent_below].commits[cidx]); - - let iter_len = matching_refs.len(); - - let commit = commit.map(|commit| { - let mut c = commit.clone(); - c.refs.retain(|ri| !matching_refs.contains(&ri.ref_name)); - c - }); - let matching_refs = matching_refs - .into_iter() - .enumerate() - .map(|(idx, ref_name)| { - let (mut first, mut last) = (false, false); - if idx == 0 { - first = true; - } - if idx + 1 == iter_len { - last = true; - } - (first, last, ref_name) - }); - for (is_first, is_last, ref_name) in matching_refs { - let new_segment = branch_segment_from_name_and_meta( - Some((ref_name, None)), - meta, - None, - worktree_by_branch, - )?; - let above_commit_idx = { - let s = &graph[above_idx]; - let cidx = commit.as_ref().and_then(|c| s.commit_index_of(c.id)); - if cidx.is_some() { - // We will take the current commit, so must commit to the one above. - // This works just once, for the actually passed parent commit. - cidx.and_then(|cidx| cidx.checked_sub(1)) - } else { - // Otherwise, assure the connection is valid by using the last commit. - s.last_commit_index() - } - }; - let new_segment = graph.connect_new_segment( - above_idx, - above_commit_idx, - new_segment, - (is_last && commit.is_some()).then_some(0), - is_last.then_some(commit.as_ref().map(|c| c.id)).flatten(), - 0, - ); - above_idx = new_segment; - if is_first { - // connect incoming edges (and disconnect from source) - // Connect to the commit if we have one. - let edges = collect_edges_at_commit_in_traversal_order( - &graph.inner, - (commit_parent_below, commit_idx), - Direction::Incoming, - ); - for edge in &edges { - graph.inner.remove_edge(edge.id); - } - // Preserve incoming traversal order after moving the edges to - // the newly created segment. - for edge in edges.into_iter().rev() { - let (target, target_cidx) = if commit_idx == Some(0) { - // the current target of the edge will be empty after we steal its commit. - // Thus, we want to keep pointing to it to naturally reach the commit later. - (edge.target, None) - } else { - // The new segment is the shortest way to the commit we loose. - (new_segment, (is_last && commit.is_some()).then_some(0)) - }; - graph.inner.add_edge( - edge.source, - target, - Edge { - src: edge.weight.src, - src_id: edge.weight.src_id, - dst: target_cidx, - dst_id: target_cidx.and_then(|_| commit.as_ref().map(|c| c.id)), - parent_order: edge.weight.parent_order, - }, - ); - } - } - if is_last { - // connect outgoing edges (and disconnect them) - if let Some((commit, commit_idx)) = commit.zip(commit_idx) { - let commit_id = commit.id; - graph[new_segment].commits.push(commit); - - reconnect_outgoing( - &mut graph.inner, - (commit_parent_below, commit_idx), - (new_segment, commit_id), - ); - } - break; - } - } - Ok(above_idx) -} - -/// This removes outgoing connections from `source_sidx` and places them on the given commit -/// of `target`. -fn reconnect_outgoing( - graph: &mut PetGraph, - (source_sidx, source_cidx): (SegmentIndex, CommitIndex), - (target_sidx, target_cidx): (SegmentIndex, gix::ObjectId), -) { - let edges = collect_edges_at_commit_in_traversal_order( - graph, - (source_sidx, Some(source_cidx)), - Direction::Outgoing, - ); - reconnect_outgoing_edges(graph, edges, (target_sidx, Some(target_cidx))) -} - -/// Delete all `edges` and recreate them with `target` as new source. -/// -/// `edges` are expected in traversal order. Petgraph traverses edges in reverse insertion order, -/// so they are re-added in reverse to preserve the same traversal order after reconnection. -fn reconnect_outgoing_edges( - graph: &mut PetGraph, - edges: Vec, - (target_sidx, target_first_commit_id): (SegmentIndex, Option), -) { - for edge in &edges { - graph.remove_edge(edge.id); - } - for edge in edges.into_iter().rev() { - let src = target_first_commit_id.and_then(|id| graph[target_sidx].commit_index_of(id)); - graph.add_edge( - target_sidx, - edge.target, - Edge { - src, - src_id: target_first_commit_id, - dst: edge.weight.dst, - dst_id: edge.weight.dst_id, - parent_order: edge.weight.parent_order, - }, - ); - } -} - -/// Collect edges at `commit` in the order petgraph currently traverses them. -/// -/// Callers that remove and re-add the returned edges must insert them in reverse -/// if they want to preserve this traversal order. -fn collect_edges_at_commit_in_traversal_order( - graph: &PetGraph, - (segment, commit): (SegmentIndex, Option), - direction: Direction, -) -> Vec { - graph - .edges_directed(segment, direction) - .filter(|&e| match direction { - Direction::Incoming => e.weight().dst == commit, - Direction::Outgoing => e.weight().src == commit, - }) - .map(Into::into) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{Commit, RefInfo, Segment}; - - fn oid(hex: &str) -> gix::ObjectId { - gix::ObjectId::from_hex(hex.as_bytes()).expect("valid test object id") - } - - fn ref_name(name: &str) -> gix::refs::FullName { - name.try_into().expect("valid full ref name") - } - - fn segment(name: &str, commit_id: gix::ObjectId) -> Segment { - Segment { - ref_info: Some(RefInfo { - ref_name: ref_name(name), - commit_id: Some(commit_id), - worktree: None, - }), - commits: vec![Commit { - id: commit_id, - parent_ids: Vec::new(), - flags: CommitFlags::default(), - refs: Vec::new(), - }], - ..Default::default() - } - } - - #[test] - fn ad_hoc_bottom_segment_selection_prefers_segment_named_by_bottom_ref() { - let commit_id = oid("1111111111111111111111111111111111111111"); - let bottom_ref = ref_name("refs/heads/bottom"); - let mut graph = Graph::default(); - let unrelated = graph.insert_segment(segment("refs/heads/unrelated", commit_id)); - let bottom = graph.insert_segment(segment("refs/heads/bottom", commit_id)); - - assert_eq!( - ad_hoc_order_bottom_segment_id(&graph, bottom_ref.as_ref(), commit_id), - Some(bottom), - "same-tip ordered branch rewrite should not pick an unrelated segment first" - ); - assert_ne!( - unrelated, bottom, - "test setup must create two distinct same-tip segments" - ); - } -} diff --git a/crates/but-graph/src/init/walk/mod.rs b/crates/but-graph/src/init/walk/mod.rs deleted file mode 100644 index 777dd64407e..00000000000 --- a/crates/but-graph/src/init/walk/mod.rs +++ /dev/null @@ -1,1206 +0,0 @@ -//! Utilities for graph-walking specifically. -use std::{ - cmp::Ordering, - collections::{BTreeMap, BTreeSet}, - ops::Deref, -}; - -use anyhow::{Context as _, bail}; -use but_core::{RefMetadata, is_workspace_ref_name, ref_metadata}; -use gix::{hashtable::hash_map::Entry, reference::Category, traverse::commit::Either}; -use petgraph::{Direction, prelude::EdgeRef}; - -use crate::{ - Commit, CommitFlags, CommitIndex, Edge, Graph, Segment, SegmentIndex, SegmentMetadata, - Worktree, - init::{ - Goals, PetGraph, - overlay::{OverlayMetadata, OverlayRepo}, - remotes, - types::{EdgeOwned, Instruction, Limit, Queue, QueueItem}, - }, -}; - -pub(crate) type RefsById = gix::hashtable::HashMap>; - -/// Assure that the first tips most important to us in `next` actually get to own commits. -/// `graph` is used to lookup segments and their names. -/// -/// The third argument is used to assure the workspace commit is always owned by the workspace segment, -/// and that otherwise the workspace segment won't own commits. -/// Note that these workspaces are identified by having metadata attached, it doesn't say anything about -/// the reference name. -pub fn prioritize_initial_tips_and_assure_ws_commit_ownership( - graph: &mut Graph, - next: &mut Queue, - (ws_tips, repo, meta): ( - Vec, - &OverlayRepo<'_>, - &OverlayMetadata<'_, T>, - ), - worktree_by_branch: &WorktreeByBranch, -) -> anyhow::Result> { - next.inner - .make_contiguous() - .sort_by_key(|(_info, _flags, instruction, _limit)| { - // put local branches first, everything else later. - graph[instruction.segment_idx()] - .ref_name() - .map(|rn| match rn.category() { - Some(Category::LocalBranch) => { - if is_workspace_ref_name(rn) { - Kind::Workspace - } else { - Kind::Local - } - } - _ => Kind::NonLocal, - }) - }); - - #[derive(Ord, PartialOrd, PartialEq, Eq)] - enum Kind { - Local, - /// Must sort after `Local` so workspaces don't capture commits by default, - /// code that follows relies on this. - Workspace, - NonLocal, - } - - let mut out = Vec::new(); - 'next_ws_tip: for ws_tip in ws_tips { - if crate::workspace::commit::is_managed_workspace_by_message( - repo.find_commit(ws_tip)?.message_raw()?, - ) { - if next.iter().filter(|(info, ..)| info.id == ws_tip).count() <= 1 { - // Assume it's the workspace tip, and it's uniquely assigned to a workspace segment. - continue 'next_ws_tip; - } - let mut segments_with_ws_tip = - next.iter() - .enumerate() - .filter_map(|(idx, (info, _, instruction, _))| { - (info.id == ws_tip).then_some((idx, instruction.segment_idx())) - }); - let (first, second) = ( - segments_with_ws_tip.next().expect("at least two"), - segments_with_ws_tip.next().expect("at least two"), - ); - if graph[first.1].workspace_metadata().is_some() { - continue 'next_ws_tip; - } - // Assure that the workspace comes first. - drop(segments_with_ws_tip); - next.inner.swap(first.0, second.0); - } else if next.iter().filter(|(info, ..)| info.id == ws_tip).count() >= 2 { - // There are multiple tips pointing to the unmanaged workspace commit. - let mut segments_with_ws_tip = - next.iter() - .enumerate() - .filter_map(|(idx, (info, _, instruction, _))| { - (info.id == ws_tip).then_some((idx, instruction.segment_idx())) - }); - let (first, second) = ( - segments_with_ws_tip.next().expect("at least two"), - segments_with_ws_tip.next().expect("at least two"), - ); - if graph[first.1].workspace_metadata().is_none() { - continue 'next_ws_tip; - } - - // Assure that the non-workspace comes first. - drop(segments_with_ws_tip); - next.inner.swap(first.0, second.0); - } else { - // Otherwise, assure there is an owner that isn't the workspace branch. - // To keep it simple, just create anon segments that are fixed up later. - - let (info, flags, _instruction, limit) = next - .iter() - .find(|t| t.0.id == ws_tip) - .cloned() - .expect("each ws-tip has one entry on queue"); - let new_anon_segment = graph.insert_segment_set_entrypoint( - branch_segment_from_name_and_meta(None, meta, None, worktree_by_branch)?, - ); - // This segment acts as stand-in - always process it even if the queue says it's done. - _ = next.push_front_exhausted(( - info, - flags, - Instruction::CollectCommit { - into: new_anon_segment, - }, - limit, - )); - out.push(new_anon_segment); - } - } - Ok(out) -} - -/// Split `sidx[commit..]` into its own segment and connect the parts. Move all connections in `commit..` -/// from `sidx` to the new segment, and return that. -/// Note that `standin` is used instead of creating a new bottom segment, and all its outgoing connections will be removed. -pub fn split_commit_into_segment( - graph: &mut Graph, - next: &mut Queue, - seen: &mut gix::revwalk::graph::IdMap, - sidx: SegmentIndex, - commit: CommitIndex, - standin: Option, -) -> anyhow::Result { - let mut bottom_segment = Segment { - commits: graph[sidx].commits.clone(), - ..Default::default() - }; - // keep only the commits before `commit`. - let commits_in_top_segment = commit; - graph[sidx].commits.truncate(commits_in_top_segment); - bottom_segment.commits = bottom_segment - .commits - .into_iter() - .skip(commits_in_top_segment) - .collect(); - let top_commit_index = graph[sidx].last_commit_index(); - let bottom_commit_id = bottom_segment.commits[0].id; - let bottom_segment = match standin { - None => graph.connect_new_segment( - sidx, - top_commit_index, - bottom_segment, - 0, - bottom_commit_id, - 0, - ), - Some(standin_sidx) => { - let outgoing_edges: Vec<_> = graph - .edges_directed(standin_sidx, Direction::Outgoing) - .map(|e| e.id()) - .collect(); - for edge_id in outgoing_edges { - graph.remove_edge(edge_id); - } - - let top_commit_id = top_commit_index.map(|idx| graph[sidx].commits[idx].id); - graph.connect_segments_with_ids( - sidx, - top_commit_index, - top_commit_id, - standin_sidx, - 0, - Some(bottom_commit_id), - 0, - ); - let s = &mut graph[standin_sidx]; - s.commits = bottom_segment.commits; - if !s.commits[0].refs.is_empty() - && let Some(ref_info) = s.ref_info.take() - { - let first = &mut s.commits[0]; - if let Some(pos) = first - .refs - .iter() - .position(|ri| ri.ref_name == ref_info.ref_name) - { - // The standin segment name is already mentioned there (as duplicate), - // So remove it. - first.refs.remove(pos); - // But if there is no name left, put our name back as it's not ambiguous. - if first.refs.is_empty() { - s.ref_info = Some(ref_info); - } - } else { - // Since there are more than one refs here, it's ambiguous, - // leave it to post-processing to resolve - s.commits[0].refs.push(ref_info); - } - } - standin_sidx - } - }; - - // Res-assign ownership to assure future queries will find the right segment. - for commit_id in graph[bottom_segment].commits.iter().map(|c| c.id) { - seen.entry(commit_id).insert(bottom_segment); - } - - // All in-flight commits now go into the new segment. - replace_queued_segments(next, sidx, bottom_segment); - split_connections(&mut graph.inner, (sidx, commit), bottom_segment)?; - Ok(bottom_segment) -} - -/// Assumes that `dst.commits == `src[src_commit..]` and will move connections down, updating their -/// indices accordingly. -fn split_connections( - graph: &mut PetGraph, - from: (SegmentIndex, CommitIndex), - dst: SegmentIndex, -) -> anyhow::Result<()> { - let (sidx, cidx) = from; - if !collect_edges_from_commit(graph, from, Direction::Incoming).is_empty() { - bail!( - "Segment {sidx:?} had incoming connections from commit {cidx}, even though these should cause splits" - ); - } - let edges = collect_edges_from_commit(graph, from, Direction::Outgoing); - for edge in &edges { - graph.remove_edge(edge.id); - } - - for edge in edges { - let edge_src_sidx = if edge - .weight - .src_id - .is_none_or(|src_id| graph[sidx].commit_index_of(src_id).is_some()) - { - if edge.source != sidx { - bail!( - "BUG: {sidx:?} contained {src_id:?}, but the edge source was {:?}", - edge.source, - src_id = edge.weight.src_id, - ); - } - sidx - } else { - // assume that the commit is now contained in the destination edge, so connect that instead. - dst - }; - let edge_dst_sidx = if edge_src_sidx == sidx { - dst - } else { - edge.target - }; - graph.add_edge( - edge_src_sidx, - edge_dst_sidx, - Edge { - src: edge - .weight - .src_id - .map(|id| { - graph[edge_src_sidx].commit_index_of(id).with_context(|| { - format!( - "BUG: source edge {edge_src_sidx:?} was supposed to contain {:?}", - edge.weight.src_id - ) - }) - }) - .transpose()?, - src_id: edge.weight.src_id, - dst: edge - .weight - .dst_id - .map(|id| { - graph[edge_dst_sidx].commit_index_of(id).with_context(|| { - format!( - "BUG: destination edge {edge_dst_sidx:?} was supposed to contain {:?}", - edge.weight.dst_id - ) - }) - }) - .transpose()?, - dst_id: edge.weight.dst_id, - parent_order: edge.weight.parent_order, - }, - ); - } - Ok(()) -} - -fn collect_edges_from_commit( - graph: &PetGraph, - (segment, commit): (SegmentIndex, CommitIndex), - direction: Direction, -) -> Vec { - graph - .edges_directed(segment, direction) - .filter(|&e| match direction { - Direction::Incoming => e.weight().dst >= Some(commit), - Direction::Outgoing => e.weight().src >= Some(commit), - }) - .map(Into::into) - .collect() -} - -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 { - *instruction_to_replace = instruction_to_replace.with_replaced_sidx(replace); - } - } -} - -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 { - *instruction_to_replace = instruction_to_replace.with_replaced_sidx(b); - } else if cmp == b { - *instruction_to_replace = instruction_to_replace.with_replaced_sidx(a); - } - } -} - -pub fn swap_commits_and_connections(graph: &mut PetGraph, a: SegmentIndex, b: SegmentIndex) { - { - let (a, b) = graph.index_twice_mut(a, b); - std::mem::swap(&mut a.commits, &mut b.commits); - } - if graph.edges(a).next().is_some() || graph.edges(b).next().is_some() { - todo!("swap connections of nodes as well") - } -} - -fn local_branches_by_id( - refs_by_id: &RefsById, - id: gix::ObjectId, -) -> Option + '_> { - refs_by_id.get(&id).map(|refs| { - refs.iter() - .filter(|rn| rn.category() == Some(Category::LocalBranch)) - }) -} - -/// Split `src_sidx` into a new segment (to receive the commit at `info`) and connect it with the new segment -/// whose id will be returned, ifโ€ฆ -/// -/// * โ€ฆthere is exactly one eligible branch to name it. -/// * โ€ฆit is a merge commit. -pub fn try_split_non_empty_segment_at_branch( - graph: &mut Graph, - src_sidx: SegmentIndex, - info: &TraverseInfo, - refs_by_id: &RefsById, - meta: &OverlayMetadata<'_, T>, - worktree_by_branch: &WorktreeByBranch, -) -> anyhow::Result> { - let src_segment = &graph[src_sidx]; - if src_segment.commits.is_empty() { - return Ok(None); - } - let maybe_segment_name_from_unambiguous_refs = - disambiguate_refs_by_branch_metadata_with_lookup((refs_by_id, info.id), meta); - let Some(maybe_segment_name) = - maybe_segment_name_from_unambiguous_refs - .map(Some) - .or_else(|| { - let want_segment_without_name = Some(None); - if info.parent_ids.len() < 2 { - return None; - } - want_segment_without_name - }) - else { - return Ok(None); - }; - - let segment_below = - branch_segment_from_name_and_meta(maybe_segment_name, meta, None, worktree_by_branch)?; - let segment_below = graph.connect_new_segment( - src_sidx, - src_segment - .last_commit_index() - .context("BUG: we are here because the segment above has commits")?, - segment_below, - 0, - info.id, - 0, - ); - Ok(Some(segment_below)) -} - -/// 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. -/// -/// Returns `true` if queueing a parent reaches the queue's hard limit. -#[expect(clippy::too_many_arguments)] -pub fn queue_parents( - next: &mut Queue, - parent_ids: &[gix::ObjectId], - flags: CommitFlags, - current_sidx: SegmentIndex, - current_cidx: CommitIndex, - mut limit: Limit, - is_shallow_boundary: bool, - commit_graph: Option<&gix::commitgraph::Graph>, - objects: &impl gix::objs::Find, - buf: &mut Vec, -) -> anyhow::Result { - if is_shallow_boundary { - return Ok(false); - } - // Don't enqueue new parents if we don't have space anymore. - if next.is_exhausted() { - return Ok(next.hard_limit_hit()); - } - if limit.is_exhausted_or_decrement(flags, next) { - return Ok(false); - } - let mut queue_is_exhausted = false; - if parent_ids.len() > 1 { - let limit_per_parent = limit.per_parent(parent_ids.len()); - for (parent_order, pid) in parent_ids.iter().enumerate() { - let instruction = Instruction::ConnectNewSegment { - parent_above: current_sidx, - at_commit: current_cidx - .try_into() - .context("commit index does not fit into u32")?, - parent_order: parent_order - .try_into() - .context("commit parent position does not fit into u32")?, - }; - let info = find(commit_graph, objects, *pid, buf)?; - queue_is_exhausted = - next.push_back_even_if_exhausted((info, flags, instruction, limit_per_parent)); - } - } else if !parent_ids.is_empty() { - let info = find(commit_graph, objects, parent_ids[0], buf)?; - queue_is_exhausted |= next.push_back_exhausted(( - info, - flags, - Instruction::CollectCommit { into: current_sidx }, - limit, - )); - } - - Ok(queue_is_exhausted) -} - -/// 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. -/// Note that `ref_name` should only be set if you are sure that it is unambiguous, and otherwise won't interfere with -/// the post-processing or the workspace projection later. -pub fn branch_segment_from_name_and_meta( - ref_name: Option<(gix::refs::FullName, Option)>, - meta: &OverlayMetadata<'_, T>, - refs_by_id_lookup: Option<(&RefsById, gix::ObjectId)>, - worktree_by_branch: &WorktreeByBranch, -) -> anyhow::Result { - branch_segment_from_name_and_meta_sibling( - ref_name, - None, - meta, - refs_by_id_lookup, - worktree_by_branch, - ) -} - -/// Like `branch_segment_from_name_and_meta`, but allows to set `sibling_sidx` as well to link -/// a new remote tracking segment to a local tracking segment. -pub fn branch_segment_from_name_and_meta_sibling( - ref_name: Option<(gix::refs::FullName, Option)>, - sibling_sidx: Option, - meta: &OverlayMetadata<'_, T>, - refs_by_id_lookup: Option<(&RefsById, gix::ObjectId)>, - worktree_by_branch: &WorktreeByBranch, -) -> anyhow::Result { - let commit_id = refs_by_id_lookup.map(|(_, id)| id); - let (ref_name, metadata) = - unambiguous_local_branch_and_segment_data(ref_name, meta, refs_by_id_lookup)?; - Ok(Segment { - metadata, - ref_info: ref_name.map(|rn| crate::RefInfo::from_ref(rn, commit_id, worktree_by_branch)), - sibling_segment_id: sibling_sidx, - ..Default::default() - }) -} - -fn unambiguous_local_branch_and_segment_data( - ref_name: Option<(gix::refs::FullName, Option)>, - meta: &OverlayMetadata<'_, T>, - 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_with_lookup(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_with_lookup( - refs_by_id_lookup: (&RefsById, gix::ObjectId), - meta: &OverlayMetadata<'_, T>, -) -> Option<(gix::refs::FullName, Option)> { - let (refs_by_id, id) = refs_by_id_lookup; - let branches = local_branches_by_id(refs_by_id, id)?; - disambiguate_refs_by_branch_metadata(branches, meta) -} - -pub fn disambiguate_refs_by_branch_metadata<'a, T: RefMetadata>( - branches: impl Iterator, - meta: &OverlayMetadata<'_, T>, -) -> Option<(gix::refs::FullName, Option)> { - let branches = branches - .map(|rn| { - ( - rn, - extract_local_branch_metadata(rn.as_ref(), meta) - .ok() - .flatten(), - ) - }) - .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 mut iter = branches.iter(); - iter.next() - .filter(|_| iter.next().is_none()) - .map(|(rn, md)| (*rn, md.as_ref())) - }) - .map(|(rn, md)| (rn.clone(), md.cloned())) -} - -fn extract_local_branch_metadata( - ref_name: &gix::refs::FullNameRef, - meta: &OverlayMetadata<'_, T>, -) -> anyhow::Result> { - if ref_name.category() != Some(Category::LocalBranch) { - return Ok(None); - } - meta.branch_opt(ref_name) - .map(|res| res.map(SegmentMetadata::Branch)) - .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, Clone)] -pub struct TraverseInfo { - inner: gix::traverse::commit::Info, - /// The pre-parsed commit if available. - commit: Option, - /// A means of sorting the entry on the queue. - pub(crate) gen_then_time: GenThenTime, -} - -#[derive(Debug, Clone)] -pub(crate) struct GenThenTime { - /// The generation number from the commit-graph cache, if there was one. - generation: Option, - /// The committer timestamp, either from the commit-graph cache, or as parsed from the commit. - committer_time: u64, -} - -impl Eq for GenThenTime {} - -impl PartialEq for GenThenTime { - fn eq(&self, other: &Self) -> bool { - self.cmp(other).is_eq() - } -} - -impl PartialOrd for GenThenTime { - fn partial_cmp(&self, other: &Self) -> Option { - self.cmp(other).into() - } -} - -/// Sort it so younger generations sort first, with more recent times (i.e. higher) as tiebreaker. -/// When the generation is unknown (`None`), treat it as `u32::MAX` (youngest possible) to match -/// git's `GENERATION_NUMBER_INFINITY` convention, ensuring unknown-generation commits are processed -/// first during traversal. -impl Ord for GenThenTime { - fn cmp(&self, other: &Self) -> Ordering { - // Using a fixed sentinel for `None` is necessary to maintain a total order - // โ€” the previous approach of falling back to time-only comparison when generations were mixed - // violated transitivity. - let gen_a = self.generation.unwrap_or(u32::MAX); - let gen_b = other.generation.unwrap_or(u32::MAX); - gen_a - .cmp(&gen_b) - .reverse() - .then_with(|| self.committer_time.cmp(&other.committer_time).reverse()) - } -} - -impl TraverseInfo { - /// Convert this traversal item into a graph [`Commit`]. - /// - /// `flags` are the graph-specific classifications collected for this - /// commit during traversal. `refs` are the fully-qualified ref names that - /// peel to this commit and should be attached to the resulting commit. - /// `worktree_by_branch` is used to annotate each ref with the worktree that - /// currently has it checked out, if any. - pub fn into_commit( - self, - flags: CommitFlags, - refs: Vec, - worktree_by_branch: &WorktreeByBranch, - ) -> anyhow::Result { - let refs: Vec<_> = refs - .into_iter() - .map(|rn| crate::RefInfo::from_ref(rn, self.inner.id, worktree_by_branch)) - .collect(); - Ok(match self.commit { - Some(commit) => Commit { - refs, - flags, - ..commit - }, - None => Commit { - id: self.inner.id, - parent_ids: self.inner.parent_ids.into_iter().collect(), - flags, - refs, - }, - }) - } -} - -impl Deref for TraverseInfo { - type Target = gix::traverse::commit::Info; - - fn deref(&self) -> &Self::Target { - &self.inner - } -} - -pub fn find( - cache: Option<&gix::commitgraph::Graph>, - objects: &impl gix::objs::Find, - id: gix::ObjectId, - buf: &mut Vec, -) -> anyhow::Result { - let mut parent_ids = gix::traverse::commit::ParentIds::new(); - let (commit, gen_then_time) = match gix::traverse::commit::find(cache, objects, &id, buf)? { - Either::CachedCommit(c) => { - let cache = cache.expect("cache is available if a cached commit is returned"); - for parent_id in c.iter_parents() { - match parent_id { - Ok(pos) => parent_ids.push({ - let parent = cache.commit_at(pos); - parent.id().to_owned() - }), - Err(_err) => { - // retry without cache - return find(None, objects, id, buf); - } - } - } - ( - None, - GenThenTime { - generation: c.generation().into(), - committer_time: c.committer_timestamp(), - }, - ) - } - Either::CommitRefIter(iter) => { - let mut committer_time = None; - for token in iter { - use gix::objs::commit::ref_iter::Token; - match token { - Ok(Token::Tree { .. }) => continue, - Ok(Token::Parent { id }) => { - parent_ids.push(id); - } - Ok(Token::Author { .. }) => continue, - Ok(Token::Committer { signature }) => { - committer_time = Some( - signature - .time() - .map(|t| t.seconds as u64) - .unwrap_or_default(), - ) - } - Ok(_other_tokens) => break, - Err(err) => return Err(err.into()), - }; - } - ( - Some(Commit { - id, - parent_ids: parent_ids.iter().cloned().collect(), - refs: Vec::new(), - flags: CommitFlags::empty(), - }), - GenThenTime { - generation: None, - committer_time: committer_time.unwrap_or_default(), - }, - ) - } - }; - - Ok(TraverseInfo { - inner: gix::traverse::commit::Info { - id, - parent_ids, - commit_time: None, - }, - commit, - gen_then_time, - }) -} - -/// Returns `[(workspace_tip, workspace_ref_name, workspace_info)]` for all available workspace, -/// or exactly one workspace if `maybe_ref_name` has workspace metadata (and only then). -/// -/// 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, or those that don't have a tip. -pub fn obtain_workspace_infos( - repo: &OverlayRepo<'_>, - maybe_ref_name: Option<&gix::refs::FullNameRef>, - meta: &OverlayMetadata<'_, T>, -) -> anyhow::Result> { - let workspaces = if let Some((ref_name, ws_data)) = maybe_ref_name - .and_then(|ref_name| { - meta.workspace_opt(ref_name) - .transpose() - .map(|res| res.map(|ws_data| (ref_name, ws_data))) - }) - .transpose()? - { - vec![(ref_name.to_owned(), ws_data)] - } else { - meta.iter_workspaces().collect() - }; - - let mut out = Vec::new(); - for (rn, data) in workspaces { - if rn.category() != Some(Category::LocalBranch) { - tracing::warn!( - "Skipped workspace at ref {rn} as workspaces can only ever be on normal branches", - ); - continue; - } - let Some(ws_tip) = try_refname_to_id(repo, rn.as_ref())? else { - tracing::warn!( - "Ignoring stale workspace ref '{rn}', which didn't exist in Git but still had workspace data", - ); - continue; - }; - - out.push((ws_tip, rn, data)) - } - - Ok(out) -} - -pub fn try_refname_to_id( - repo: &OverlayRepo<'_>, - refname: &gix::refs::FullNameRef, -) -> anyhow::Result> { - Ok(repo - .try_find_reference(refname)? - .map(|mut r| r.peel_to_id()) - .transpose()? - .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. -/// -/// # Performance Warning -/// -/// This function is critical to performance, and ideally is called less. But when it is called, -/// it must be fast, hence the manual implementation of the TopoWalk, which is about 60% faster. -/// -/// To validate your changes, clone https://@github.com/schacon/homebrew-cask.git, and run -/// `cargo run --release --bin but-testing -- -dd -C /path/to/homebrew-cask graph -s -l 300 --no-debug-workspace` -/// -/// If this gets slower, the change isn't good. -pub fn propagate_flags_downward( - graph: &mut PetGraph, - flags_to_add: CommitFlags, - dst_sidx: SegmentIndex, - dst_commit: Option, - needs_leafs: bool, -) -> Option> { - let mut visited = gix::hashtable::HashSet::default(); - let mut leafs = needs_leafs.then(Vec::new); - let mut stack = vec![(dst_sidx, dst_commit)]; - - while let Some((segment, first_commit_index)) = stack.pop() { - // Select the range of commits to process in this segment - let commit_range = if let Some(start_commit_idx) = first_commit_index { - let segment_ref = &graph[segment]; - start_commit_idx..segment_ref.commits.len() - } else { - // Empty segment, no commits to process - 0..0 - }; - - // Mark all commits in range with flags - if !commit_range.is_empty() { - for commit in &mut graph[segment].commits[commit_range.clone()] { - commit.flags |= flags_to_add; - } - } - - // Process outgoing edges - let mut neighbors = graph - .neighbors_directed(segment, petgraph::Direction::Outgoing) - .detach(); - - // Track edges for leaf detection - let mut edge_count = 0; - while let Some((edge_idx, target_segment)) = neighbors.next(graph) { - edge_count += 1; - let edge = &graph[edge_idx]; - - // Skip edges that don't originate from our commit range - if let Some(src_cidx) = edge.src - && !commit_range.contains(&src_cidx) - { - continue; - } - - // For DAG, we can visit each node multiple times from different parents, - // but we want to process each commit-id only once in this walk. - let next_commit = edge.dst_id; - if let Some(commit_id) = next_commit - && visited.insert(commit_id) - { - stack.push((target_segment, edge.dst)); - } - } - - // Track leaf segments (those with no outgoing edges from the processed range) - if edge_count == 0 - && let Some(ref mut leafs) = leafs - { - leafs.push(segment); - } - } - - leafs.filter(|v| !v.is_empty()) -} - -pub(crate) struct RemoteQueueOutcome { - /// The new tips to queue officially later. - pub items_to_queue_later: Vec, - /// A way for the remote to find the local tracking branch. - pub maybe_make_id_a_goal_so_remote_can_find_local: CommitFlags, - /// A way for the local tracking branch to find the remote. - /// Only set if `items_to_queue_later` is also set. - pub limit_to_let_local_find_remote: CommitFlags, -} - -/// 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. -/// Note that remotes fully obey the limit. -/// If the created remote segment belongs to the segment of `local_tracking_sidx`, return its Segment index along with its name. -#[expect(clippy::too_many_arguments)] -pub fn try_queue_remote_tracking_branches( - repo: &OverlayRepo<'_>, - refs: &[gix::refs::FullName], - graph: &mut Graph, - target_symbolic_remote_names: &[String], - configured_remote_tracking_branches: &BTreeSet, - target_refs: &[gix::refs::FullName], - meta: &OverlayMetadata<'_, T>, - id: gix::ObjectId, - limit: Limit, - goals: &mut Goals, - next: &Queue, - worktree_by_branch: &WorktreeByBranch, - commit_graph: Option<&gix::commitgraph::Graph>, - objects: &impl gix::objs::Find, - buf: &mut Vec, -) -> anyhow::Result { - let mut goal_flags = CommitFlags::empty(); - let mut limit_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( - repo, - rn.as_ref(), - target_symbolic_remote_names, - configured_remote_tracking_branches, - )? - else { - continue; - }; - if target_refs.contains(&remote_tracking_branch) { - continue; - } - // Note that we don't connect the remote segment yet as it still has to reach - // any segment really. It could be anywhere and point to anything. - let Some(remote_tip) = try_refname_to_id(repo, remote_tracking_branch.as_ref())? else { - continue; - }; - - // It can happen a remote is in the workspace and was already queued as workspace tip. - // Don't double-queue. - if next.iter().any(|t| { - t.0.id == remote_tip - && graph[t.2.segment_idx()] - .ref_name() - .is_some_and(|rn| rn == remote_tracking_branch.as_ref()) - }) { - continue; - }; - let remote_segment = - graph.insert_segment_set_entrypoint(branch_segment_from_name_and_meta( - Some((remote_tracking_branch.clone(), None)), - meta, - None, - worktree_by_branch, - )?); - - let remote_limit = limit.with_indirect_goal(id, goals); - let self_flags = goals.flag_for(remote_tip).unwrap_or_default(); - limit_flags |= self_flags; - // These flags are to be attached to `id` so it can propagate itself later. - // The remote limit is for searching `id`. - // Also, this makes the local tracking branch look for its remote, which is important - // if the remote is far away as the local branch was rebased somewhere far ahead of the remote. - goal_flags |= remote_limit.goal_flags(); - let remote_tip_info = find(commit_graph, objects, remote_tip, buf)?; - queue.push(( - remote_tip_info, - self_flags, - Instruction::CollectCommit { - into: remote_segment, - }, - remote_limit, - )); - } - Ok(RemoteQueueOutcome { - items_to_queue_later: queue, - maybe_make_id_a_goal_so_remote_can_find_local: goal_flags, - limit_to_let_local_find_remote: limit_flags, - }) -} - -#[expect(clippy::too_many_arguments)] -pub fn possibly_split_occupied_segment( - graph: &mut Graph, - seen: &mut gix::revwalk::graph::IdMap, - next: &mut Queue, - id: gix::ObjectId, - propagated_flags: CommitFlags, - src_sidx: SegmentIndex, - limit: Limit, - parent_order: u32, -) -> 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 - // so it doesn't own the existing commit. - if graph[dst_sidx].workspace_metadata().is_some() && - graph[src_sidx].ref_name() - .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); - - // Assure the first commit doesn't name the new owner segment. - { - let s: &mut Segment = &mut graph[src_sidx]; - if let Some(c) = s.commits.first_mut() { - c.refs.retain(|ri| Some(&ri.ref_name) != s.ref_info.as_ref().map(|rn| &rn.ref_name)) - } - // 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 { - // Re-use an existing empty segment to better integrate them into the graph, and to prevent loose segments - // just 'hanging around'. This can happen particularly for remote tracking branches which get to their commit - // too late, but might also be possible for other nodes we have created with a name pre-emptively. - // And even though there is a good reason to do what we do with remote tracking segments, - // maybe all this can be simpler? - let standin = { - let s = &graph[src_sidx]; - (top_sidx == src_sidx - && s.commits.is_empty() - && s.ref_info.is_some() - && graph - .neighbors_directed(src_sidx, Direction::Incoming) - .next() - .is_none() - && graph[bottom_sidx] - .commits - .first() - .is_some_and(|c| c.flags.is_remote())) - .then_some(src_sidx) - }; - let new_bottom_sidx = - split_commit_into_segment(graph, next, seen, bottom_sidx, bottom_cidx, standin)?; - bottom_sidx = new_bottom_sidx; - bottom_cidx = 0; - } - - // Standins will cause this, avoid self-connection. - if top_sidx != bottom_sidx { - graph.connect_segments_with_ids( - top_sidx, - top_cidx, - None, - bottom_sidx, - bottom_cidx, - None, - parent_order, - ); - } - 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; - let new_flags = propagated_flags | top_flags | bottom_flags; - - // Only propagate if there is something new as propagation is slow - // Note that we currently assume that the flags are different also to get leafs, even though these - // depend on flags to propagate. This will be an issue, but seems to work well for all known cases. - let needs_leafs = !limit.goal_reached(); - let leafs = if new_flags != bottom_flags - || (needs_leafs - && next - .iter() - .any(|(_, _, _, tip_limit)| !tip_limit.goal_flags().contains(limit.goal_flags()))) - { - propagate_flags_downward( - &mut graph.inner, - new_flags, - bottom_sidx, - Some(bottom_cidx), - needs_leafs, - ) - } else { - None - }; - - if let Some(leafs) = leafs { - propagate_goals_of_reachable_tips(next, leafs, limit.goal_flags()); - } - - // Find the tips that saw this commit, and adjust their limit it that would extend it. - // The commit is the one we hit, but seen from the newly split segment which should never be empty. - // TODO: merge this into the new propagation based one. - let bottom_commit_goals = Limit::new(None) - .additional_goal( - graph[bottom_sidx] - .commits - .first() - .expect("we just split it out into its own segment") - .flags, - ) - .goal_flags(); - for queued_tip_limit in next.iter_mut().filter_map(|(_, _, _, limit)| { - limit - .goal_flags() - .intersects(bottom_commit_goals) - .then_some(limit) - }) { - queued_tip_limit.adjust_limit_if_bigger(limit); - } - Ok(()) -} - -// Find all tips that are scheduled to be put into one of the `reachable_segments` -// (reachable from the commit we just ran into) -fn propagate_goals_of_reachable_tips( - next: &mut Queue, - reachable_segments: Vec, - goal_flags: CommitFlags, -) { - for (_, _, instruction, limit) in next.iter_mut() { - if reachable_segments.contains(&instruction.segment_idx()) { - *limit = limit.additional_goal(goal_flags); - } - } -} - -/// Stop traversal once only integrated tips with reached goals are left. -/// 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) -> anyhow::Result<()> { - if next.is_exhausted() { - return Ok(()); - } - let all_integrated_and_done = next.iter().all(|(_id, flags, _instruction, tip_limit)| { - flags.contains(CommitFlags::Integrated) && tip_limit.goal_reached() - }); - if !all_integrated_and_done { - return Ok(()); - } - let ep = graph.entrypoint()?; - if ep - .segment - .non_empty_flags_of_first_commit() - .is_some_and(|flags| flags.contains(CommitFlags::Integrated)) - { - return Ok(()); - } - - next.exhaust(); - Ok(()) -} - -pub(crate) type WorktreeByBranch = BTreeMap>; - -impl crate::RefInfo { - pub(crate) fn from_ref( - ref_name: gix::refs::FullName, - commit_id: impl Into>, - worktree_by_branch: &WorktreeByBranch, - ) -> Self { - let worktree = worktree_by_branch - .get(&ref_name) - .and_then(|worktrees| worktrees.first().cloned()); - Self { - ref_name, - commit_id: commit_id.into(), - worktree, - } - } -} - -#[cfg(test)] -mod tests; diff --git a/crates/but-graph/src/projection/workspace/init.rs b/crates/but-graph/src/projection/workspace/init.rs deleted file mode 100644 index ed1a35a056d..00000000000 --- a/crates/but-graph/src/projection/workspace/init.rs +++ /dev/null @@ -1,1489 +0,0 @@ -use std::{ - cell::RefCell, - collections::{BTreeSet, HashSet}, -}; - -use anyhow::Context; -use bstr::ByteSlice; -use but_core::ref_metadata::{ - self, StackId, - StackKind::{Applied, AppliedAndUnapplied}, -}; -use gix::{ObjectId, refs::Category}; -use itertools::Itertools; -use petgraph::{Direction, prelude::EdgeRef, visit::NodeRef}; -use tracing::instrument; - -use crate::{ - CommitFlags, Graph, Segment, SegmentIndex, Workspace, - utils::SeenTable, - workspace::{ - Stack, StackCommit, StackCommitFlags, StackSegment, TargetCommit, TargetRef, WorkspaceKind, - workspace::{ - WorkspaceReconciliationInput, WorkspaceState, find_segment_owner_indexes_by_refname, - }, - }, -}; - -pub(crate) enum Downgrade { - /// Allows to turn a workspace above a selection to be downgraded back to the selection if it turns - /// out to be outside the workspace. - /// This is typically what you want when producing a workspace for display, as the workspace then isn't relevant. - Allow, - /// Use this if the closest workspace is what you want, even if the reference in question is below the workspace lower bound. - Disallow, -} - -/// Shared graph-level workspace analysis before projection-only cleanup. -/// -/// `WorkspaceFrame` identifies the workspace tip, entrypoint relationship, -/// target-side traversal context, and lower bound. Final projection turns it -/// into [`WorkspaceState`] by collecting stacks and then applying display-only -/// pruning/enrichment. Reconciliation turns it into -/// [`WorkspaceReconciliationInput`] by collecting the same raw stack paths but -/// keeping only the fields needed to reshape graph segments before projection. -struct WorkspaceFrame { - /// Workspace classifier derived from the entrypoint or containing workspace segment. - kind: WorkspaceKind, - /// Managed workspace metadata, if the frame is backed by a GitButler workspace ref. - metadata: Option, - /// Segment that acts as the workspace tip for stack collection. - ws_tip_segment_id: SegmentIndex, - /// Original entrypoint segment when it is inside or below a containing workspace. - entrypoint_sidx: Option, - /// Commit id of the computed workspace lower bound. - lower_bound: Option, - /// Segment that owns the computed workspace lower-bound commit. - lower_bound_segment_id: Option, - /// Resolved target ref used as the workspace integration frame. - target_ref: Option, - /// Resolved target commit used as an additional lower-bound anchor. - target_commit: Option, -} - -/// Return whether `s` is named by an internal GitButler ref. -/// -/// Stack collection normally treats local branch names as stack boundaries: -/// another local branch means another user-visible stack segment starts there. -/// Refs below `refs/heads/gitbutler/` are implementation refs, especially -/// workspace refs, and should not shape user-visible stacks. When collection -/// encounters such a segment it continues through it instead of stopping or -/// splitting the stack at that internal name. -fn segment_name_is_special(s: &Segment) -> bool { - s.ref_name() - .is_some_and(|rn| rn.as_bstr().starts_with_str("refs/heads/gitbutler/")) -} - -impl Graph { - /// Analyze the current graph starting at its [entrypoint](Self::entrypoint()). - /// - /// No matter what, each location of `HEAD`, which corresponds to the entrypoint, can be represented as workspace. - /// Further, the most expensive operations we perform to query additional commit information by reading it, but we - /// only do so on the ones that the user can interact with. - /// - /// Target commit ids and integrated traversal tips can extend the - /// workspace to include these commits to define its lowest base. - #[instrument( - name = "Graph::into_workspace", - level = "trace", - skip(self), - err(Debug) - )] - pub fn into_workspace(self) -> anyhow::Result { - let state = self.to_workspace_state(Downgrade::Allow)?; - Ok(Workspace::from_state(self, state)) - } - - pub(crate) fn to_workspace_state( - &self, - downgrade: Downgrade, - ) -> anyhow::Result { - let frame = self.workspace_frame(downgrade)?; - let stacks = self.workspace_stacks(&frame)?; - let mut target_ref = frame.target_ref; - - if let Some(target) = target_ref.as_mut() { - target.compute_and_set_commits_ahead(self, frame.lower_bound_segment_id); - } - - let mut ws = WorkspaceState { - id: frame.ws_tip_segment_id, - kind: frame.kind, - stacks, - lower_bound: frame.lower_bound, - lower_bound_segment_id: frame.lower_bound_segment_id, - target_ref, - target_commit: frame.target_commit, - metadata: frame.metadata, - }; - - ws.prune_archived_segments(); - ws.prune_integrated_segments(self); - ws.mark_remote_reachability(self)?; - ws.add_commits_on_remote(self); - ws.truncate_single_stack_to_match_base(); - Ok(ws) - } - - pub(crate) fn workspace_reconciliation_input( - &self, - ) -> anyhow::Result> { - let frame = self.workspace_frame(Downgrade::Disallow)?; - let Some(metadata) = frame.metadata.clone() else { - return Ok(None); - }; - let stacks = self.workspace_stacks(&frame)?; - Ok(Some(WorkspaceReconciliationInput { - id: frame.ws_tip_segment_id, - stacks, - lower_bound_segment_id: frame.lower_bound_segment_id, - target_ref: frame.target_ref, - target_commit: frame.target_commit, - metadata, - })) - } - - fn workspace_frame(&self, downgrade: Downgrade) -> anyhow::Result { - let ( - mut kind, - mut metadata, - mut ws_tip_segment_id, - entrypoint_sidx, - entrypoint_first_commit_flags, - ) = { - let ep = self.entrypoint()?; - match ep.segment.workspace_metadata() { - None => { - // Skip over empty segments. - if let Some((maybe_integrated_flags, sidx_of_flags)) = self - .resolve_to_unambiguously_pointed_to_commit(ep.segment.id) - .map(|(c, sidx)| (c.flags, sidx)) - .filter(|(f, _sidx)| f.contains(CommitFlags::InWorkspace)) - { - // search the (for now just one) workspace upstream and use it instead, - // mark this segment as entrypoint. - // Note that at this time the entrypoint could still be below the fork-point of the workspace. - let ws_segment = self - .find_segment_upwards(sidx_of_flags, |s| { - s.workspace_metadata().is_some() - }) - .with_context(|| { - format!( - "BUG: should have found upstream workspace segment from {sidx_of_flags:?} as commit is marked as such" - ) - })?; - - ( - WorkspaceKind::managed(&ws_segment.ref_info)?, - ws_segment.workspace_metadata().cloned(), - ws_segment.id, - Some(ep.segment.id), - maybe_integrated_flags, - ) - } else { - ( - WorkspaceKind::AdHoc, - None, - ep.segment.id, - None, - CommitFlags::empty(), - ) - } - } - Some(meta) => ( - WorkspaceKind::managed(&ep.segment.ref_info)?, - Some(meta.clone()), - ep.segment.id, - None, - CommitFlags::empty(), - ), - } - }; - let configured_target_ref = self - .project_meta - .target_ref - .as_ref() - .and_then(|target_ref| { - TargetRef::from_ref_name_without_commits_ahead(target_ref, self) - }); - let configured_target_commit = self - .project_meta - .target_commit_id - .and_then(|target_commit_id| TargetCommit::from_commit(target_commit_id, self)); - let mut target_ref = configured_target_ref - .clone() - .or_else(|| self.integrated_tip_target_ref()); - let mut target_commit = configured_target_commit - .clone() - .or_else(|| self.integrated_tip_target_commit(target_ref.as_ref())); - let integrated_tip_segments = - self.integrated_tip_segments_excluding_target_ref_tip(target_ref.as_ref()); - - let ws_lower_bound = if kind.has_managed_ref() { - self.compute_lowest_base( - ComputeBaseTip::WorkspaceCommit(ws_tip_segment_id), - target_ref.as_ref(), - target_commit.as_ref(), - &integrated_tip_segments, - ) - .or_else(|| { - // target not available? Try the base of the workspace itself - if self - .inner - .neighbors_directed(ws_tip_segment_id, Direction::Outgoing) - .count() - == 1 - { - None - } else { - self.find_best_effort_workspace_base( - self.inner - .neighbors_directed(ws_tip_segment_id, Direction::Outgoing), - ) - .and_then(|base| self[base].commits.first().map(|c| (c.id, base))) - } - }) - } else { - // Auto-set the target by its remote. - if target_ref.is_none() { - let ws_head_segment = &self[ws_tip_segment_id]; - target_ref = ws_head_segment - .remote_tracking_ref_name - .as_ref() - .zip(ws_head_segment.remote_tracking_branch_segment_id) - .map(|(target_ref, target_sidx)| TargetRef { - ref_name: target_ref.to_owned(), - segment_index: target_sidx, - commits_ahead: 0, - }); - } - if target_ref.is_some() - || target_commit.is_some() - || !integrated_tip_segments.is_empty() - { - self.compute_lowest_base( - ComputeBaseTip::SingleBranch(ws_tip_segment_id), - target_ref.as_ref(), - target_commit.as_ref(), - &integrated_tip_segments, - ) - } else { - None - } - }; - - let (mut lower_bound, mut lower_bound_segment_id) = ws_lower_bound - .map(|(a, b)| (Some(a), Some(b))) - .unwrap_or_default(); - - // The entrypoint is integrated and has a workspace above it. - // Right now we would be using it, but will discard it if the entrypoint is *at* or *below* the merge-base. - if let Some(((_lowest_base, lowest_base_sidx), ep_sidx)) = ws_lower_bound - .filter(|_| { - matches!(downgrade, Downgrade::Allow) - && entrypoint_first_commit_flags.contains(CommitFlags::Integrated) - }) - .zip(entrypoint_sidx) - && (ep_sidx == lowest_base_sidx - || self - .find_map_downwards_along_first_parent(ep_sidx, |s| { - (s.id == lowest_base_sidx).then_some(()) - }) - .is_none()) - { - // We cannot reach the lowest workspace base, by definition reachable through any path downward, - // so we are outside the workspace limits which is above us. Turn the data back into entrypoint-only. - ws_tip_segment_id = ep_sidx; - kind = WorkspaceKind::AdHoc; - target_ref = configured_target_ref; - target_commit = configured_target_commit; - metadata = None; - lower_bound = None; - lower_bound_segment_id = None; - } - - if kind.has_managed_ref() && self[ws_tip_segment_id].commits.is_empty() { - let ref_info = self[ws_tip_segment_id] - .ref_info - .as_ref() - .expect("BUG: must be set or we wouldn't be here"); - kind = WorkspaceKind::ManagedMissingWorkspaceCommit { - ref_info: ref_info.clone(), - }; - } - - Ok(WorkspaceFrame { - kind, - metadata, - ws_tip_segment_id, - entrypoint_sidx, - lower_bound, - lower_bound_segment_id, - target_ref, - target_commit, - }) - } - - fn workspace_stacks(&self, frame: &WorkspaceFrame) -> anyhow::Result> { - let mut stacks = vec![]; - let (lowest_base, lowest_base_sidx) = (frame.lower_bound, frame.lower_bound_segment_id); - let preferred_parent_order_by_commit = frame - .entrypoint_sidx - .map(|entrypoint| { - self.preferred_parent_order_by_commit( - frame.ws_tip_segment_id, - entrypoint, - frame.lower_bound_segment_id, - ) - }) - .unwrap_or_default(); - if frame.kind.has_managed_ref() { - let mut used_stack_ids = BTreeSet::default(); - for stack_top_sidx in self - .inner - .neighbors_directed(frame.ws_tip_segment_id, Direction::Outgoing) - { - let stack_segment = &self[stack_top_sidx]; - let has_seen_base = RefCell::new(false); - stacks.extend( - self.collect_stack_segments( - stack_top_sidx, - frame.entrypoint_sidx, - &preferred_parent_order_by_commit, - |s| { - let stop = true; - // The lowest base is a segment that all stacks will run into. - // If we meet it, we are done. Note how we ignored the integration state - // as pruning of fully integrated stacks happens later. - if Some(s.id) == lowest_base_sidx { - has_seen_base.replace(true); - return stop; - } - // Assure entrypoints get their own segments - if s.id != stack_top_sidx && Some(s.id) == frame.entrypoint_sidx { - return stop; - } - // Check for anonymous segments with sibling ID - these know their - // named counterparts, and we want to set the name, but they must - // be in their own stack-segment. - if s.ref_info.is_none() && s.sibling_segment_id.is_some() { - return stop; - } - if segment_name_is_special(s) { - return !stop; - } - match ( - &stack_segment.ref_info, - s.ref_name() - .filter(|rn| rn.category() == Some(Category::LocalBranch)), - ) { - (Some(_), Some(_)) | (None, Some(_)) => stop, - (Some(_), None) | (None, None) => !stop, - } - }, - |s| { - !*has_seen_base.borrow() - && self - .inner - .neighbors_directed(s.id, Direction::Incoming) - .all(|n| n.id() != frame.ws_tip_segment_id) - }, - |s| Some(s.id) == frame.lower_bound_segment_id && s.metadata.is_none(), - )? - .and_then(|segments| { - let stack_id = find_matching_stack_id( - frame.metadata.as_ref(), - &segments, - &mut used_stack_ids, - ); - // If we find no stack ID, then the segment is not included in the workspace metadata, - // indicating it's ignored. Just to be even more certain, if it starts with a commit - // that is the workspace base, then we definitely don't want to show it - it's unapplied. - if stack_id.is_none_or(|(_id, in_workspace)| !in_workspace) - && segments - .first() - .is_some_and(|s| s.commits.first().map(|c| c.id) == lowest_base) - { - None - } else { - Some(Stack::from_base_and_segments( - &self.inner, - segments, - stack_id.map(|(id, _in_workspace)| id), - )) - } - }), - ); - } - } else { - let start = &self[frame.ws_tip_segment_id]; - let has_seen_base = RefCell::new(false); - let maybe_stack = self - .collect_stack_segments( - start.id, - None, - &preferred_parent_order_by_commit, - |s| { - let stop = true; - if segment_name_is_special(s) { - return !stop; - } - // Cut the stack off at the lower base if we have one. This is only - // the case if we have a remote. - if Some(s.id) == lowest_base_sidx { - if self.is_ordered_ad_hoc_lower_bound(start, s) { - return stop; - } - has_seen_base.replace(true); - return stop; - } - match (&start.ref_info, &s.ref_info) { - (Some(_), Some(_)) | (None, Some(_)) => stop, - (Some(_), None) | (None, None) => !stop, - } - }, - |_s| !*has_seen_base.borrow(), - // Never discard stacks - |_s| false, - )? - .map(|segments| { - Stack::from_base_and_segments( - &self.inner, - segments, - Some(StackId::single_branch_id()), - ) - }); - if let Some(stack) = maybe_stack { - stacks.push(stack); - } else { - tracing::warn!( - "Didn't get a single stack for AdHoc workspace - this is unexpected" - ); - } - } - Ok(stacks) - } - - /// Return whether `candidate` is below `start` in a persisted ad-hoc branch order. - /// - /// In ad-hoc/single-branch mode, ordinary local branches below the entrypoint can become - /// workspace lower bounds. GitButler-created empty dependent branches are different: their - /// lower branch is still part of the same user-visible stack. This lets projection keep - /// walking through ordered branch segments instead of truncating the stack at the next local - /// branch boundary. - fn is_ordered_ad_hoc_lower_bound(&self, start: &Segment, candidate: &Segment) -> bool { - let Some(start_ref) = start.ref_name() else { - return false; - }; - let Some(candidate_ref) = candidate.ref_name() else { - return false; - }; - if candidate_ref.category() != Some(Category::LocalBranch) { - return false; - } - - self.ad_hoc_branch_stack_orders.iter().any(|order| { - let start_idx = order.iter().position(|branch| branch.as_ref() == start_ref); - let candidate_idx = order - .iter() - .position(|branch| branch.as_ref() == candidate_ref); - start_idx - .zip(candidate_idx) - .is_some_and(|(start_idx, candidate_idx)| start_idx < candidate_idx) - }) - } - - /// Compute the lowest base (i.e. the highest generation) for the - /// workspace projection. - /// - /// `tip` identifies the workspace side. For a workspace commit, its direct - /// outgoing segments are used as stack tips; for a single-branch workspace, - /// the branch segment itself is used. `target_ref` and `target_commit` - /// identify the ordinary target side and are folded with the workspace - /// stack tips using the legacy pairwise merge-base behavior: candidates - /// are folded in order, and if a candidate pair has no merge-base, the - /// previous base candidate is kept instead of clearing the workspace base. - /// `integrated_tip_segments` are tips of interest that represent integrated - /// or past target positions. They are considered only after the ordinary - /// base is found, and can lower that base so the workspace does not appear - /// to lose stacks merely because they are now reachable from target tips. - /// - /// Returns `Some((lowest_base, segment_idx_with_lowest_base))`. - /// - /// ## Note - /// - /// This is a best-effort merge-base fold for workspace lower-bound compatibility. - /// - /// Target refs and target commits preserve the legacy pairwise fold - /// behavior. Integrated tips then lower that base if they are farther down - /// the common history. - // TODO: actually compute the lowest base, see `first_merge_base()` which should be `lowest_merge_base()` by itself, - // accounting for finding the lowest of all merge-bases which would be assumed to be reachable by all segments - // searching downward, a necessary trait for many search problems. - fn compute_lowest_base( - &self, - tip: ComputeBaseTip, - target_ref: Option<&TargetRef>, - target_commit: Option<&TargetCommit>, - integrated_tip_segments: &[SegmentIndex], - ) -> Option<(ObjectId, SegmentIndex)> { - // It's important to not start from the tip, but instead find paths to the merge-base from each stack individually. - // Otherwise, we may end up with a short path to a segment that isn't actually reachable by all stacks. - let (tips, actual_tip) = match tip { - ComputeBaseTip::WorkspaceCommit(ws_tip) => ( - self.inner - .neighbors_directed(ws_tip, Direction::Outgoing) - .collect(), - ws_tip, - ), - ComputeBaseTip::SingleBranch(tip) => (vec![tip], tip), - }; - let mut count = 0; - let base_segments = tips - .iter() - .copied() - .chain(target_ref.map(|t| t.segment_index)) - .chain(target_commit.map(|t| t.segment_index)) - .chain(integrated_tip_segments.iter().copied()); - - let base = self.find_best_effort_workspace_base(base_segments.inspect(|_| count += 1))?; - - if count < 2 || base == actual_tip { - match tip { - ComputeBaseTip::WorkspaceCommit(_) => { - // In workspace mode, we get natural results if we don't accept tips == base situations, - // which would mean the workspace tip is included in the target. - None - } - ComputeBaseTip::SingleBranch(_) => { - // In single-branch mode, and if the checkout branch is directly reachable from the target - // which typically is its remote, it should just be empty. Allow this for now, and see what happens. - self.resolve_to_unambiguously_pointed_to_commit(base) - .map(|(c, sidx)| (c.id, sidx)) - } - } - } else { - self.resolve_to_unambiguously_pointed_to_commit(base) - .map(|(c, sidx)| (c.id, sidx)) - } - } - - /// Fold pairwise merge-bases for workspace lower-bound projection. - /// - /// A workspace lower-bound is used to frame legacy presentation and mutation compatibility. - /// Historically, disjoint inputs kept the previous candidate instead of clearing the lower - /// bound, so keep that behavior local to workspace projection rather than weakening - ///[`Self::find_merge_base_octopus()`]. - fn find_best_effort_workspace_base( - &self, - segments: impl IntoIterator, - ) -> Option { - segments - .into_iter() - .reduce(|base, segment| self.find_merge_base(base, segment).unwrap_or(base)) - } - - pub(super) fn integrated_tip_segments(&self) -> Vec { - self.integrated_tip_segments_excluding_target_ref_tip(None) - } - - /// Return target-remote tip segments that provide extra target context. - /// - /// These are resolved from effective traversal tips with - /// [`crate::init::TipRole::TargetRemote`]. They are used as additional - /// lower-bound candidates and as a signal that integrated commits should - /// not be pruned from the workspace projection yet. - /// - /// If `target_ref` is provided, its own tip commit is excluded. The target - /// ref is already represented separately as [`TargetRef`], so including - /// the same commit again would make an ordinary configured target look like - /// additional target context. Distinct target-remote tips, including lower - /// extra targets, remain in the returned list. - fn integrated_tip_segments_excluding_target_ref_tip( - &self, - target_ref: Option<&TargetRef>, - ) -> Vec { - self.workspace_projection_target_remote_tips() - .filter_map(|tip| { - TargetCommit::from_commit(tip.id, self) - .filter(|target| { - !self.target_ref_points_to_commit(target_ref, target.commit_id) - }) - .map(|target| target.segment_index) - }) - .unique() - .collect() - } - - /// Return the first named integrated tip that can act as the workspace's target ref. - /// - /// This is needed for graphs built with [`Graph::from_commit_traversal_tips()`], where callers - /// can provide a named [`crate::init::TipRole::TargetRemote`] target without workspace metadata. In that mode - /// there is no configured `target_ref` to resolve, but workspace projection can still expose the - /// named integrated tip as the target ref for presentation and target-related queries. - fn integrated_tip_target_ref(&self) -> Option { - if self.has_workspace_metadata_tip() { - return None; - } - self.workspace_projection_target_remote_tips() - .filter_map(|tip| tip.ref_name.as_ref()) - .find_map(|ref_name| TargetRef::from_ref_name_without_commits_ahead(ref_name, self)) - } - - /// Return the *lowest* target-remote tip that can act as the workspace's effective target commit. - /// - /// This is needed for graphs built with [`Graph::from_commit_traversal_tips()`], where callers - /// can provide an explicit [`crate::init::TipRole::TargetRemote`] target without workspace metadata. In that - /// mode there is no stored `target_commit_id` to resolve, but workspace projection still needs a - /// target commit to frame the lower bound and workspace view. When multiple target remotes are - /// available, choose the lowest one, i.e. the one with the highest segment generation. - fn integrated_tip_target_commit(&self, target_ref: Option<&TargetRef>) -> Option { - self.workspace_projection_target_remote_tips() - .filter_map(|tip| TargetCommit::from_commit(tip.id, self)) - .filter(|target| !self.target_ref_points_to_commit(target_ref, target.commit_id)) - .max_by_key(|target| self[target.segment_index].generation) - } - - /// Target-remote traversal tips that workspace projection can use as target context. - /// - /// `Graph::traversal_tips` stores every effective traversal tip. Workspace - /// projection treats all target-remote tips the same here, named or - /// anonymous, and lets graph position decide which one is useful for lower - /// bound computation. Workspace metadata still wins for configured - /// `target_ref` and stored `target_commit_id`; these tips are fallback - /// target context derived from traversal. - // TDOO: `traversal_tips` include the tips discovered in workspace metadata already, so the project code - // doesn't have to access them specifically. - fn workspace_projection_target_remote_tips(&self) -> impl Iterator { - self.traversal_tips - .iter() - .filter(|tip| tip.role.is_integrated()) - } - - /// Return `true` if there is a traversal tip with workspace metadata attached. - fn has_workspace_metadata_tip(&self) -> bool { - self.traversal_tips - .iter() - .any(|tip| matches!(tip.metadata, Some(crate::SegmentMetadata::Workspace(_)))) - } - - /// Return whether `target_ref` resolves to `commit_id` in the final graph. - /// - /// `target_ref` is the configured or inferred target branch, while - /// `commit_id` comes from a target-remote traversal tip. These can differ: - /// the tip may be an extra target/lower-bound commit, a persisted target - /// commit, or another target-remote root that is not the branch tip. Only - /// when both resolve to the same commit is the target-remote tip redundant - /// with the target ref and safe to ignore as additional target context. - fn target_ref_points_to_commit( - &self, - target_ref: Option<&TargetRef>, - commit_id: gix::ObjectId, - ) -> bool { - target_ref - .and_then(|target| self.tip_skip_empty(target.segment_index)) - .is_some_and(|commit| commit.id == commit_id) - } - - /// Return commit-specific parent choices for the path from `start` down to `entrypoint`. - /// - /// Each graph edge knows which parent of its source commit it represents. - /// For a merge commit `M` with parents `[A, B]`, the edge from `M` to `A` - /// has parent order `0`, and the edge from `M` to `B` has parent order `1`. - /// This records the edge parent order for each source commit on the path - /// that reaches the entrypoint, then sorts by commit id so stack collection - /// can binary-search it while walking downward. - fn preferred_parent_order_by_commit( - &self, - start: SegmentIndex, - entrypoint: SegmentIndex, - lower_bound: Option, - ) -> Vec<(ObjectId, u32)> { - let mut seen = self.seen_table(); - let mut out = Vec::new(); - if !self.collect_preferred_parent_order_by_commit( - start, - entrypoint, - lower_bound, - &mut seen, - &mut out, - ) { - return Vec::new(); - } - out.sort_by_key(|lhs| lhs.0); - out.dedup_by(|lhs, rhs| lhs.0 == rhs.0); - out - } - - /// Depth-first search for one downward segment path from `current` to `entrypoint`. - /// - /// Edges point from a segment toward its parents in commit history. When the recursion finds - /// `entrypoint`, it returns `true` and unwinds, appending one `(source_commit_id, parent_order)` - /// pair for each edge on the successful path. The source commit id is enough to identify the - /// merge commit during later stack walking, and `parent_order` identifies which parent edge - /// keeps that walk on the entrypoint path. - /// - /// If `lower_bound` is set, the search does not descend past it. Reaching the lower-bound - /// segment fails the current branch unless that segment is also `entrypoint`. This keeps the - /// entrypoint path search within the same workspace range that stack collection will later use. - /// - /// Branches that do not lead to `entrypoint` are backtracked by truncating `out` to its length - /// before that branch was explored. `seen` prevents revisiting shared history in the segmented - /// graph, so the search cost is linear in the bounded reachable segment subgraph from `current`: - /// `O(segments + edges)` up to the lower bound, with `O(segments)` visited storage and - /// `O(path length)` output. - fn collect_preferred_parent_order_by_commit( - &self, - current: SegmentIndex, - entrypoint: SegmentIndex, - lower_bound: Option, - seen: &mut SeenTable, - out: &mut Vec<(ObjectId, u32)>, - ) -> bool { - if current == entrypoint { - return true; - } - if Some(current) == lower_bound { - return false; - } - if !seen.insert_unseen(current) { - return false; - } - - for edge in self.inner.edges_directed(current, Direction::Outgoing) { - let before = out.len(); - if self.collect_preferred_parent_order_by_commit( - edge.target(), - entrypoint, - lower_bound, - seen, - out, - ) { - if let Some(src_id) = edge.weight().src_id { - out.push((src_id, edge.weight().parent_order)); - } - return true; - } - out.truncate(before); - } - false - } -} - -enum ComputeBaseTip { - /// The tip is a workspace commit, and we should consider all of its stacks. - WorkspaceCommit(SegmentIndex), - /// Use the tip directly. - SingleBranch(SegmentIndex), -} - -/// This works as named segments have been created in a prior step. Thus, we are able to find best matches by -/// the amount of matching names, probably. -/// Note that we find applied stack-ids first, then try again with unapplied ones, and indicate if it was applied or not. -/// Update `seen` with the stack_id we find and avoid reusing seen stack ids. -fn find_matching_stack_id( - metadata: Option<&ref_metadata::Workspace>, - segments: &[StackSegment], - seen: &mut BTreeSet, -) -> Option<(StackId, bool)> { - let metadata = metadata?; - - fn ref_names_with_weight( - s: &StackSegment, - ) -> impl Iterator { - s.ref_info - .as_ref() - .map(|ri| (100_000, ri.ref_name.as_ref())) - .into_iter() - .chain( - s.commits - .iter() - .flat_map(|c| c.refs.iter().map(|ri| (1, ri.ref_name.as_ref()))), - ) - } - - segments - .iter() - .flat_map(|s| { - ref_names_with_weight(s).filter_map(|(weight, rn)| { - metadata.stacks(AppliedAndUnapplied).find_map(|meta_stack| { - if let Some(bidx) = meta_stack - .branches - .iter() - .enumerate() - .find_map(|(bidx, b)| (rn == b.ref_name.as_ref()).then_some(bidx)) - { - let priority = if bidx == 0 { 3 } else { 1 }; - Some(( - if meta_stack.is_in_workspace() { - weight * 2 - } else { - weight - } * priority, - meta_stack.id, - meta_stack.is_in_workspace(), - )) - } else { - None - } - }) - }) - }) - .sorted_by(|l, r| l.0.cmp(&r.0).reverse()) - .map(|(_weight, stack_id, in_workspace)| (stack_id, in_workspace)) - .find(|(stack_id, _)| seen.insert(*stack_id)) -} - -/// Traversals -impl Graph { - /// Return the ancestry of `start` along the preferred parent path, itself included, until `stop` returns `true`. - /// Also return the segment that we stopped at. - /// **Important**: `stop` is not called with `start`, this is a feature. - /// - /// `preferred_parent_order_by_commit` is a sorted list of `(commit_id, parent_order)` pairs - /// produced from the path between the workspace tip and the traversal entrypoint. During the - /// stack walk, each outgoing edge is matched by its source commit id and parent order. If a - /// matching hint exists, that edge is followed so a merge can walk through the parent that - /// reaches the entrypoint. Without a hint for the current source commit, the preferred path - /// falls back to the first-parent edge. - /// - /// The `stop` signal is ignored for unnamed segments (no `ref_info`) whose `sibling_segment_id` - /// points to a segment already collected in the output. This prevents the traversal from stopping - /// at an ancestor-link segment that merely reconnects to a workspace branch we are already traversing. - /// - /// Note that the traversal assumes as well-segmented graph without cycles. - fn collect_first_parent_segments_until<'a>( - &'a self, - start: &'a Segment, - preferred_parent_order_by_commit: &[(ObjectId, u32)], - mut stop: impl FnMut(&Segment) -> bool, - ) -> (Vec<&'a Segment>, Option<&'a Segment>) { - let mut out = vec![start.id]; - let mut stopped_at = None; - self.visit_segments_downward_with_parent_order_hints_exclude_start( - start.id, - preferred_parent_order_by_commit, - |next| { - if stop(next) - && !(next.ref_info.is_none() - && next - .sibling_segment_id - .is_some_and(|sid| out.contains(&sid))) - { - stopped_at = Some(next.id); - return true; - } - out.push(next.id); - false - }, - ); - ( - out.into_iter().map(|sidx| &self[sidx]).collect(), - stopped_at.map(|sidx| &self[sidx]), - ) - } - - /// Visit the ancestry of `start` along the preferred parent path, itself excluded, until `stop` returns `true`. - /// **Important**: `stop` is not called with `start`, this is a feature. - /// - /// `preferred_parent_order_by_commit` is a sorted list of `(commit_id, parent_order)` pairs. - /// Each outgoing edge is matched by its source commit id and parent order. If a matching - /// hint exists, that edge is followed. Without a hint for the current source commit, the - /// walk falls back to the first-parent edge. - pub fn visit_segments_downward_with_parent_order_hints_exclude_start( - &self, - start: SegmentIndex, - preferred_parent_order_by_commit: &[(ObjectId, u32)], - mut stop: impl FnMut(&Segment) -> bool, - ) { - let mut next = self.next_segment_downward(start, preferred_parent_order_by_commit); - let mut seen = self.seen_table(); - while let Some(sidx) = next { - let segment = &self[sidx]; - if stop(segment) { - return; - } - next = if seen.insert_unseen(sidx) { - self.next_segment_downward(sidx, preferred_parent_order_by_commit) - } else { - None - }; - } - } - - fn next_segment_downward( - &self, - segment: SegmentIndex, - preferred_parent_order_by_commit: &[(ObjectId, u32)], - ) -> Option { - let preferred_parent_order = self[segment] - .commits - .last() - .and_then(|commit| { - preferred_parent_order_by_commit - .binary_search_by(|(id, _)| id.cmp(&commit.id)) - .ok() - }) - .map(|hint_idx| preferred_parent_order_by_commit[hint_idx].1); - for edge in self.inner.edges_directed(segment, Direction::Outgoing) { - if preferred_parent_order.is_none_or(|order| edge.weight().parent_order == order) { - return Some(edge.target()); - } - } - None - } - - /// Visit all segments from `start`, excluding, and return once `find` returns something mapped from the - /// first suitable segment it encountered. - fn find_map_downwards_along_first_parent( - &self, - start: SegmentIndex, - mut find: impl FnMut(&Segment) -> Option, - ) -> Option { - let mut out = None; - self.visit_segments_downward_along_first_parent_exclude_start(start, |s| { - if let Some(res) = find(s) { - out = Some(res); - true - } else { - false - } - }); - out - } - /// Return `OK(None)` if the post-process discarded this segment after collecting it in full as it was not - /// local a local branch. - /// - /// `entrypoint_sidx` is passed to set the collected segment as entrypoint automatically. - /// - /// `is_one_past_end_of_stack_segment(s)` returns `true` if the graph segment `s` should be considered past the - /// currently collected stack segment. If `false` is returned, it will become part of the current stack segment. - /// It's not called for the first segment, so you can use it to compare the first with other segments. - /// - /// `starts_next_stack_segment(s)` returns `true` if a new stack segment should be started with `s` as first member, - /// or `false` if the stack segments are complete and with it all stack segments. - /// - /// `discard_stack(stack_segment)` returns `true` if after collecting everything, we'd still want to discard the - /// whole stack due to custom rules, after assuring the stack segment is no entrypoint. - /// It's also called to determine if a stack-segment (from the bottom of the stack upwards) should be discarded. - /// If the stack is empty at the end, it will be discarded in full. - fn collect_stack_segments( - &self, - from: SegmentIndex, - mut entrypoint_sidx: Option, - preferred_parent_order_by_commit: &[(ObjectId, u32)], - mut is_one_past_end_of_stack_segment: impl FnMut(&Segment) -> bool, - mut starts_next_stack_segment: impl FnMut(&Segment) -> bool, - mut discard_stack: impl FnMut(&StackSegment) -> bool, - ) -> anyhow::Result>> { - let mut out = Vec::new(); - let mut next = Some(from); - while let Some(from) = next.take() { - let start = &self[from]; - let (segments, stopped_at) = self.collect_first_parent_segments_until( - start, - preferred_parent_order_by_commit, - &mut is_one_past_end_of_stack_segment, - ); - let mut segment = StackSegment::from_graph_segments(&segments, self)?; - if entrypoint_sidx.is_some_and(|id| segment.id == id) { - segment.is_entrypoint = true; - entrypoint_sidx = None; - } - out.push(segment); - next = stopped_at - .filter(|s| starts_next_stack_segment(s)) - .map(|s| s.id); - } - - fn is_entrypoint_or_local(s: &StackSegment) -> bool { - if s.is_entrypoint { - return true; - } - s.ref_name() - .and_then(|rn| rn.category()) - .is_none_or(|c| c == Category::LocalBranch) - } - - let is_pruned = |s: &StackSegment| !is_entrypoint_or_local(s); - // Prune the whole stack if we start with unwanted segments. - if out - .first() - .is_some_and(|s| is_pruned(s) || discard_stack(s)) - { - tracing::warn!( - "Ignoring stack {:?} ({:?}) as it is pruned", - out.first().and_then(|s| s.ref_info.as_ref()), - from, - ); - return Ok(None); - } - - Ok((!out.is_empty()).then_some(out)) - } - - /// Visit all segments across all connections, including `start` and return the segment for which `f(segment)` returns `true`. - /// There is no traversal pruning. - pub(crate) fn find_segment_upwards( - &self, - start: SegmentIndex, - mut f: impl FnMut(&Segment) -> bool, - ) -> Option<&Segment> { - let mut out = None; - self.visit_all_segments_including_start_until(start, Direction::Incoming, |s| { - if f(s) { - out = Some(s.id); - true - } else { - false - } - }); - out.map(|sidx| &self[sidx]) - } -} - -/// More processing -impl WorkspaceState { - /// Match the archived flag from our workspace metadata by name with actual segments and prune them, - /// top to bottom, but only if they are empty all the way down for safety. - /// Doing so naturally shows segments that we have to show, independently of the archived flag. - /// - /// Match the archived flag by name, that's all we have. - /// Note that we chose to not make `archived` intrusive and a member of the respective segment data - /// despite other portions of the code possibly being in a good position to do that. Ultimately, they - /// all match by name, and we just keep the 'archived' handling localised - /// (possibly allowing it to be turned off, etc). - /// - /// Remove the whole stack if everything is archived. - fn prune_archived_segments(&mut self) { - let Some(md) = &self.metadata else { - return; - }; - let archived_stack_branches = md.stacks(Applied).flat_map(|s| { - s.branches - .iter() - .filter_map(|s| s.archived.then_some(s.ref_name.as_ref())) - }); - let mut empty_stacks_to_remove = Vec::new(); - for archived_ref_name in archived_stack_branches { - let Some((stack_idx, segment_idx)) = - find_segment_owner_indexes_by_refname(&self.stacks, archived_ref_name) - else { - continue; - }; - let stack = &mut self.stacks[stack_idx]; - let all_downwards_are_empty = stack.segments[segment_idx..] - .iter() - .all(|s| s.commits.is_empty()); - if !all_downwards_are_empty { - continue; - } - stack.segments.truncate(segment_idx); - if stack.segments.is_empty() { - empty_stacks_to_remove.push(stack_idx); - } - } - - empty_stacks_to_remove.sort(); - for stack_idx_to_remove in empty_stacks_to_remove.into_iter().rev() { - let stack = self.stacks.remove(stack_idx_to_remove); - tracing::warn!( - "Pruned stack {stack_id:?} from workspace as all its segments were archived", - stack_id = stack.id - ) - } - } - - /// Remove integrated commits and empty branches at the bottom of each - /// stack, but only those at or below the workspace's target commit. - /// Integrated commits above the target commit are kept until the user advances - /// the target via upstream integration. - // TODO: the per-stack fork point is recomputed on every projection rather than - // stored; persisting it would avoid re-deriving the target trunk each build. - fn prune_integrated_segments(&mut self, graph: &Graph) { - // Integrated-commit pruning only applies to workspaces tracking an upstream - // target ref; without one, leave the stacks untouched. - if self.target_ref.is_none() { - return; - } - // Extra integrated tips mean upstream advanced past the stored target. Bail only - // if there's no stored target *commit* to bound pruning against. - let upstream_advanced_past_target = !graph - .integrated_tip_segments_excluding_target_ref_tip(self.target_ref.as_ref()) - .is_empty(); - if self.target_commit.is_none() && upstream_advanced_past_target { - return; - } - // TODO: it seems like we assume this is the lowest commit, - // but don't chose by generation. - let target_segment_index = if let Some(tc) = self.target_commit.as_ref() { - tc.segment_index - } else if let Some(tr) = self.target_ref.as_ref() { - tr.segment_index - } else { - return; - }; - - // Only build the segment set the chosen branch actually uses. - let prune_segments = if upstream_advanced_past_target { - // The target's first-parent trunk. Commits reaching the target only via a merge's - // second parent are off it, so a branch's own work is preserved; only shared trunk - // on a stack's first-parent spine is pruned. - let mut segments = HashSet::new(); - graph.visit_segments_downward_along_first_parent_include_start( - target_segment_index, - |s| { - segments.insert(s.id); - false - }, - ); - segments - } else { - // The target segment itself plus all its ancestors (walk toward ancestors). - let mut segments = HashSet::new(); - graph.visit_all_segments_excluding_start_until( - target_segment_index, - Direction::Outgoing, - |s| { - segments.insert(s.id); - false - }, - ); - segments.insert(target_segment_index); - segments - }; - - let metadata = self.metadata.as_ref(); - let keep_empty_segment_ids = if matches!(self.kind, WorkspaceKind::AdHoc) { - let ordered_refs = graph - .ad_hoc_branch_stack_orders - .iter() - .flatten() - .map(|ref_name| ref_name.as_ref()) - .collect::>(); - graph - .node_weights() - .filter_map(|segment| { - (segment.id == self.id - || segment - .ref_name() - .is_some_and(|ref_name| ordered_refs.contains(ref_name))) - .then_some(segment.id) - }) - .collect::>() - } else { - HashSet::new() - }; - let keep_if_fully_integrated = - upstream_advanced_past_target && !matches!(self.kind, WorkspaceKind::AdHoc); - for stack in &mut self.stacks { - // Upstream advanced: floor the stack at its fork point but keep a fully-integrated - // tip in managed workspaces so it survives for `integrate_upstream`. Single-branch - // mode keeps the branch shell, but prunes integrated target/base commits from it. - prune_integrated_stack_segments(stack, &prune_segments, keep_if_fully_integrated); - remove_empty_branches(stack, metadata, &keep_empty_segment_ids); - if upstream_advanced_past_target { - // Pruning moved the stack's bottom; refresh its base to the new fork point. - stack.recompute_last_segment_base(&graph.inner); - } - } - self.stacks.retain(|stack| !stack.segments.is_empty()); - } - - /// Trace the remotes of each segments down to their segment or other segments and set the commit flags accordingly - /// to indicate if a commit in the workspace is reachable, and how. - fn mark_remote_reachability(&mut self, graph: &Graph) -> anyhow::Result<()> { - let remote_refs: Vec<_> = self - .stacks - .iter() - .flat_map(|s| { - s.segments.iter().filter_map(|s| { - s.remote_tracking_ref_name - .as_ref() - .cloned() - .zip(s.remote_tracking_branch_segment_id) - }) - }) - .collect(); - for (remote_tracking_ref_name, remote_sidx) in remote_refs { - let mut may_take_commits_from_first_remote = graph[remote_sidx].commits.is_empty(); - graph.visit_all_segments_including_start_until(remote_sidx, Direction::Outgoing, |s| { - let prune = !s.commits.iter().all(|c| c.flags.is_remote()) - // Do not 'steal' commits from other known remote segments while they are officially connected, - // unless we started out empty. That means ambiguous ownership, as multiple remotes point - // to the same commit. - || { - let mut prune = s.id != remote_sidx - && s.ref_name() - .is_some_and(|orn| orn.category() == Some(Category::RemoteBranch)); - if prune && may_take_commits_from_first_remote { - prune = false; - may_take_commits_from_first_remote = false; - } - prune - }; - if prune { - // See if this segment links to a commit we know as local, and mark it accordingly, - // along with all segments in that stack. - for stack in &mut self.stacks { - let Some((first_segment, first_commit_index)) = - stack.segments.iter().enumerate().find_map(|(os_idx, os)| { - os.commits_by_segment - .iter() - .find_map(|(sidx, commit_ofs)| { - (*sidx == s.id).then_some(commit_ofs) - }) - .map(|commit_ofs| (os_idx, *commit_ofs)) - }) - else { - continue; - }; - - let mut first_commit_index = Some(first_commit_index); - for segment in &mut stack.segments[first_segment..] { - let remote_reachable_flags = - if segment.remote_tracking_ref_name.as_ref() - == Some(&remote_tracking_ref_name) - { - StackCommitFlags::ReachableByMatchingRemote - } else { - StackCommitFlags::empty() - } | StackCommitFlags::ReachableByRemote; - for commit in &mut segment.commits - [first_commit_index.take().unwrap_or_default()..] - { - commit.flags |= remote_reachable_flags; - } - } - // keep looking - other stacks can repeat the segment! - continue; - } - } - prune - }); - } - Ok(()) - } - - /// For each local segment that has a remote tracking branch, walk the remote - /// side and collect commits that exist on the remote but not locally: - /// - commits that are purely remote (never existed locally or pre-rebase versions), and - /// - non-integrated commits from upper stack segments that are still on the - /// remote (the "branch split" case โ€” a previously combined push left the - /// remote pointing at commits that now belong to branch above it). - fn add_commits_on_remote(&mut self, graph: &Graph) { - for stack in &mut self.stacks { - let mut above_commit_ids = HashSet::new(); - for seg_idx in 0..stack.segments.len() { - let Some(rsidx) = stack.segments[seg_idx].remote_tracking_branch_segment_id else { - // Still accumulate this segment's commits for lower segments. - above_commit_ids.extend(stack.segments[seg_idx].commits.iter().map(|c| c.id)); - continue; - }; - - // All-parents walk: collect commits from *fully*-remote segments. - // Stop at segments that contain non-remote commits or that belong - // to another remote-branch, unless this segment is empty and - // the first reachable remote commits can't be uniquely attributed. - // This happens if multiple remote tracking branches point to the same commit, - // which is when ours might be a virtual segment because it was traversed after - // the segment that was prioritized to own the commit. - // So `may_take_from_first_remote` allows us to pretend that these commits - // belong to our remote (which they do as well from a pure graph perspective). - let mut may_take_from_first_remote = graph[rsidx].commits.is_empty(); - let mut remote_commits = Vec::new(); - graph.visit_all_segments_including_start_until( - rsidx, - Direction::Outgoing, - |segment| { - if !segment.commits.iter().all(|c| c.flags.is_remote()) { - return true; - } - if segment.id != rsidx - && segment - .ref_name() - .is_some_and(|rn| rn.category() == Some(Category::RemoteBranch)) - { - if may_take_from_first_remote { - may_take_from_first_remote = false; - } else { - return true; - } - } - for commit in &segment.commits { - remote_commits.push(StackCommit::from_graph_commit(commit)); - } - false - }, - ); - - // First-parent walk: detect non-integrated commits from upper - // stack segments that are still reachable by the remote tracking branch. - if !above_commit_ids.is_empty() { - let mut seen: HashSet<_> = remote_commits.iter().map(|c| c.id).collect(); - let mut extra = Vec::new(); - graph.visit_segments_downward_along_first_parent_exclude_start(rsidx, |s| { - if s.ref_name() - .is_some_and(|rn| rn.category() == Some(Category::RemoteBranch)) - { - return true; - } - for commit in &s.commits { - if above_commit_ids.contains(&commit.id) - && !commit.flags.contains(CommitFlags::Integrated) - && seen.insert(commit.id) - { - extra.push(StackCommit::from_graph_commit(commit)); - } - } - false - }); - remote_commits.extend(extra); - } - - stack.segments[seg_idx].commits_on_remote = remote_commits; - - // Accumulate this segment's commits for lower segments. - above_commit_ids.extend(stack.segments[seg_idx].commits.iter().map(|c| c.id)); - } - } - } - - /// If there is a single stack and the base happens to be itself (which happens if the stack is directly integrated/inline with the target), - /// then empty all commits and segment-related metadata. - fn truncate_single_stack_to_match_base(&mut self) { - if self.stacks.len() != 1 { - return; - } - let Some(stack) = self.stacks.first_mut() else { - return; - }; - let stack_is_base = stack - .segments - .first() - .zip(self.lower_bound_segment_id) - .is_some_and(|(segment, base)| - // We can go by branch ID as this also means the first commit is the one that is the base. - // This should be fine, as these kinds of stacks/segments should have at least one commit. - // There is no hard guarantee though, so let's see. - segment.id == base); - if !stack_is_base { - return; - } - - stack.segments.drain(1..); - let first_segment = stack.segments.first_mut().expect("non-empty"); - first_segment.commits.clear(); - first_segment.commits_by_segment.clear(); - } -} - -/// Prune the integrated tail whose graph segment is in `prune_segments`; commits in other -/// segments are kept (e.g. above the target, or reaching it only via a merge's 2nd parent). -/// -/// With `keep_if_fully_integrated`, a stack whose every commit would be pruned is left -/// untouched, keeping a fully-integrated branch's tip visible for `integrate_upstream`. -fn prune_integrated_stack_segments( - stack: &mut Stack, - prune_segments: &HashSet, - keep_if_fully_integrated: bool, -) { - // Walk stack segments bottom-up, then graph-segment blocks bottom-up within - // each stack segment. Stop at the first graph segment block that is either - // not fully integrated or not in `prune_segments`. - let mut cut: Option<(usize, usize)> = None; - // Whether any commit would survive the cut (not integrated, or not in `prune_segments`). - let mut has_surviving_commit = false; - 'outer: for seg_idx in (0..stack.segments.len()).rev() { - let seg = &stack.segments[seg_idx]; - - if seg.commits.is_empty() { - continue; - } - - if seg.commits_by_segment.is_empty() { - if commits_are_integrated(&seg.commits) && prune_segments.contains(&seg.id) { - cut = Some((seg_idx, 0)); - continue; - } - has_surviving_commit = true; - break 'outer; - } - - for block_idx in (0..seg.commits_by_segment.len()).rev() { - let (segment_id, start_offset) = seg.commits_by_segment[block_idx]; - let end_offset = seg - .commits_by_segment - .get(block_idx + 1) - .map_or(seg.commits.len(), |(_, offset)| *offset); - let commits = &seg.commits[start_offset..end_offset]; - - if prune_segments.contains(&segment_id) && commits_are_integrated(commits) { - cut = Some((seg_idx, start_offset)); - } else { - has_surviving_commit = true; - break 'outer; - } - } - } - - let Some((cut_seg_idx, cut_offset)) = cut else { - return; - }; - - // The whole stack is integrated trunk. While upstream is ahead, keep it (e.g. a - // fully-integrated branch behind its remote) rather than pruning it out of existence. - if keep_if_fully_integrated && !has_surviving_commit { - return; - } - - stack.segments[cut_seg_idx].commits.truncate(cut_offset); - stack.segments[cut_seg_idx] - .commits_by_segment - .retain(|(_, offset)| *offset < cut_offset); - - // Remove all stack segments below the cut. If the cut emptied the topmost - // stack segment, keep it so `remove_empty_branches` can decide whether its - // branch ref should be preserved, e.g. a metadata-tracked branch at the fork point. - let keep = if stack.segments[cut_seg_idx].commits.is_empty() && cut_seg_idx > 0 { - cut_seg_idx - } else { - cut_seg_idx + 1 - }; - stack.segments.truncate(keep); -} - -fn commits_are_integrated(commits: &[StackCommit]) -> bool { - commits - .iter() - .all(|commit| commit.flags.contains(StackCommitFlags::Integrated)) -} - -/// Remove empty segments unless they are mentioned in workspace metadata -/// (e.g. a branch the user just added at the fork point with no commits yet). -fn remove_empty_branches( - stack: &mut Stack, - metadata: Option<&but_core::ref_metadata::Workspace>, - keep_empty_segment_ids: &HashSet, -) { - let own_metadata_stack = stack.id.and_then(|stack_id| { - metadata.and_then(|meta| meta.stacks(Applied).find(|ms| ms.id == stack_id)) - }); - stack.segments.retain(|seg| { - !seg.commits.is_empty() - || keep_empty_segment_ids.contains(&seg.id) - || own_metadata_stack.is_some_and(|ms| { - seg.ref_info - .as_ref() - // NOTE: `!b.archived` compensates for `prune_archived_segments` - // running *before* integrated-commit pruning โ€” archived segments - // that still had commits are skipped there, then emptied here. - // Once metadata is kept trimmed and up-to-date we can drop this. - .is_some_and(|ri| { - ms.branches - .iter() - .any(|b| b.ref_name == ri.ref_name && !b.archived) - }) - }) - }); -} diff --git a/crates/but-graph/src/projection/workspace/mod.rs b/crates/but-graph/src/projection/workspace/mod.rs deleted file mode 100644 index bec89dc9ecb..00000000000 --- a/crates/but-graph/src/projection/workspace/mod.rs +++ /dev/null @@ -1,337 +0,0 @@ -use anyhow::Context as _; -use but_core::ref_metadata; - -use super::Stack; -use crate::{Graph, SegmentIndex}; - -pub(super) mod api; -mod init; -pub(crate) use init::Downgrade; - -/// A workspace reference is a list of [Stacks](Stack), with a reference to the underlying [`Graph`]. -#[derive(Clone)] -pub struct Workspace { - /// The underlying graph for providing simplified access to data. - pub graph: Graph, - /// An ID which uniquely identifies the [graph segment](crate::Segment) that represents the tip of the workspace. - pub id: SegmentIndex, - /// Specify what kind of workspace this is. - pub kind: WorkspaceKind, - /// One or more stacks that live in the workspace, in order of parents of the workspace commit if there are more than one. - pub stacks: Vec, - /// The bound can be imagined as the commit from which all other commits in the workspace originate. - /// It can also be imagined to be the delimiter at the bottom beyond which nothing belongs to the workspace, - /// as antagonist to the first commit in tip of the segment with `id`, serving as first commit that is - /// inside the workspace. - /// - /// As such, it's always the longest path to the first shared commit with the target among - /// all of our stacks, or it is the first commit that is shared among all of our stacks in absence of a target. - /// One can also think of it as the starting point from which all workspace commits can be reached when - /// following all incoming connections and stopping at the tip of the workspace. - /// - /// It is `None` there is only a single stack and no target, so nothing was integrated. - pub lower_bound: Option, - /// If `lower_bound` is set, this is the segment owning the commit. - pub lower_bound_segment_id: Option, - /// The target, as identified by a remote tracking branch, to integrate workspace stacks into. - /// - /// If `None`, and if `target_commit` is `None`, this is a local workspace that doesn't know when - /// possibly pushed branches are considered integrated. This happens when there is a local branch - /// checked out without a remote tracking branch. - pub target_ref: Option, - /// A commit *typically* reachable by [`Self::target_ref`] which we chose to keep as base. That way we can extend the workspace - /// past its computed lower bound. - /// - /// Indeed, it's valid to not set the reference, and to only set the commit which should act as an integration base. - /// This can be done by direct user override, who simply wants to cut off history at a certain movable point in time. - /// - /// It is also valid to have this field point to the same Segment as [Self::target_ref]. Both have different purposes, - /// semantically. - pub target_commit: Option, - /// Read-only workspace metadata with additional information, or `None` if nothing was present. - /// If this is `Some()` the `kind` is always [`WorkspaceKind::Managed`] - /// - /// # WARNING - /// - /// Do not use this data to understand the workspace. It's unreconciled metadata which may - /// have nothing to do with the actual workspace. - /// To see that, look at [Self::stacks]. - pub metadata: Option, -} - -/// A copy of all workspace state, to pass it around internally. -pub(crate) struct WorkspaceState { - pub id: SegmentIndex, - pub kind: WorkspaceKind, - pub stacks: Vec, - pub lower_bound: Option, - pub lower_bound_segment_id: Option, - pub target_ref: Option, - pub target_commit: Option, - pub metadata: Option, -} - -/// Graph-level workspace facts needed while reconciling a traversed graph. -/// -/// This deliberately stops before the final workspace projection pruning and -/// remote-display enrichment. Reconciliation needs the workspace frame and -/// current graph paths, not a finished [`Workspace`]. -pub(crate) struct WorkspaceReconciliationInput { - /// Segment that represents the workspace tip/ref being reconciled. - /// - /// In managed mode this is the workspace ref segment. Reconciliation uses - /// it as the root for inserting or reordering virtual stack branch - /// segments according to workspace metadata. - pub id: SegmentIndex, - /// Current graph paths below the workspace tip, grouped using the same - /// first-parent path rules as projection, but before projection-only - /// pruning and remote display enrichment. - /// - /// Reconciliation uses these paths to discover which already-traversed - /// segments can receive metadata-defined branch segments. - pub stacks: Vec, - /// Segment that owns the computed workspace lower-bound commit, regardless - /// of whether that segment is currently part of [`Self::stacks`]. - /// - /// This is the full frame-of-reference lower bound used to decide where - /// workspace stack collection stops and which base candidates are relevant. - /// It may point to a target/integrated segment outside the workspace paths, - /// unlike [`Self::lower_bound_segment_id_in_workspace()`]. - pub lower_bound_segment_id: Option, - /// Resolved target ref for the workspace, if one is available. - /// - /// Reconciliation uses the target segment to avoid creating independent - /// branch segments from target-side history and to identify candidates - /// where the target is connected from above. - pub target_ref: Option, - /// Resolved target commit for the workspace, if one is available. - /// - /// This can come from workspace metadata or traversal target context. It is - /// used as another lower-bound/candidate anchor when reconciling branches - /// against the traversed graph. - pub target_commit: Option, - /// Workspace metadata that defines the desired applied/unapplied stacks, - /// branch order, branch names, and target settings. - /// - /// This is the non-Git input reconciliation applies to the traversed graph. - pub metadata: ref_metadata::Workspace, -} - -impl WorkspaceReconciliationInput { - /// Return the lower-bound segment only if it is currently part of one of - /// [`Self::stacks`]. - /// - /// This is narrower than [`Self::lower_bound_segment_id`]. Reconciliation - /// uses it for the "split lower bound out of a named stack segment" fixup, - /// which is only valid when that lower-bound segment is inside the current - /// workspace stack paths. If the lower bound comes from the target side or - /// another integrated context outside the workspace paths, this returns - /// `None` to avoid mutating unrelated graph structure. - pub fn lower_bound_segment_id_in_workspace(&self) -> Option { - self.lower_bound_segment_id.filter(|lb_sidx| { - self.stacks - .iter() - .flat_map(|s| s.segments.iter().map(|s| s.id)) - .any(|sid| sid == *lb_sidx) - }) - } -} - -impl Workspace { - fn from_state( - graph: Graph, - WorkspaceState { - id, - kind, - stacks, - lower_bound, - lower_bound_segment_id, - target_ref, - target_commit, - metadata, - }: WorkspaceState, - ) -> Self { - Workspace { - graph, - id, - kind, - stacks, - lower_bound, - lower_bound_segment_id, - target_ref, - target_commit, - metadata, - } - } -} - -/// A classifier for the workspace. -#[derive(Debug, Clone)] -pub enum WorkspaceKind { - /// The `HEAD` is pointing to a dedicated workspace reference, like `refs/heads/gitbutler/workspace`. - /// This also means that we have a workspace commit that `ref_name` points to directly, which is also owned - /// exclusively by the underlying segment. - Managed { - /// The name of the reference pointing to the workspace commit, along with workspace info. Useful for deriving the workspace name. - ref_info: crate::RefInfo, - }, - /// Information for when a workspace reference was *possibly* advanced by hand and does not point to a - /// managed workspace commit (anymore). - /// That workspace commit, may be reachable by following the first parent from the workspace reference. - /// - /// Note that the stacks that follow *will* be in unusable if the workspace commit is in a segment below, - /// but typically is usable if there is just a single real stack, or any amount of virtual stacks below - /// (i.e. those that have no commits and are just marked by references). - ManagedMissingWorkspaceCommit { - /// The name of the reference pointing to the workspace commit. Useful for deriving the workspace name. - ref_info: crate::RefInfo, - }, - /// A segment is checked out directly. - /// - /// It can be inside or outside a workspace. - /// If the respective segment is [not named](Workspace::ref_name), this means the `HEAD` id detached. - /// The commit that the working tree is at is always implied to be the first commit of the [`crate::workspace::StackSegment`] - /// at [`Workspace::id`]. - AdHoc, -} - -impl WorkspaceKind { - /// Return `true` if this workspace has a managed reference, meaning we control certain aspects of it - /// by means of workspace metadata that is associated with that ref. - /// If `false`, we are more conservative and may not support all features. - pub fn has_managed_ref(&self) -> bool { - matches!( - self, - WorkspaceKind::Managed { .. } | WorkspaceKind::ManagedMissingWorkspaceCommit { .. } - ) - } - - /// Return `true` if we have a workspace commit, a commit that merges all stacks together. - /// Implies `has_managed_ref() == true`. - pub fn has_managed_commit(&self) -> bool { - matches!(self, WorkspaceKind::Managed { .. }) - } -} - -impl WorkspaceKind { - fn managed(ref_info: &Option) -> anyhow::Result { - let ref_info = ref_info - .clone() - .context("BUG: managed workspaces must always be on a named segment")?; - Ok(WorkspaceKind::Managed { ref_info }) - } -} - -/// Information about the target reference, which marks a portion in the commit-graph -/// that the workspace wants to integrate with. -#[derive(Clone)] -pub struct TargetRef { - /// The name of the target branch, i.e. the branch that all [Stacks](Stack) want to get merged into. - /// Typically, this is `refs/remotes/origin/main`. - pub ref_name: gix::refs::FullName, - /// The index to the respective segment in the graph, it's the segment with [`Self::ref_name`] as name. - pub segment_index: SegmentIndex, - /// The amount of *all* commits that aren't included in any segment in the workspace, they are in its future. - pub commits_ahead: usize, -} - -/// Segment indices are ephemeral; keep them out of snapshot output. -impl std::fmt::Debug for TargetRef { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("TargetRef") - .field("ref_name", &self.ref_name) - .field("commits_ahead", &self.commits_ahead) - .finish() - } -} - -/// Information about the target commit, which marks a portion in the commit-graph -/// that the workspace wants to integrate with. -/// -/// It's an unnamed point of interest which may -/// be set by any means. Typically, it's set by using a stored value, which makes it -/// a point in time at which we have seen the [`TargetRef`]. -#[derive(Clone)] -pub struct TargetCommit { - /// The hash of the commit that was once included in the [target ref](TargetRef), and that we remember to expand - /// the reach of the workspace. - pub commit_id: gix::ObjectId, - /// The index to the respective segment in the graph that contains [`Self::commit_id`]. - pub segment_index: SegmentIndex, -} - -/// Segment indices are ephemeral; keep them out of snapshot output. -impl std::fmt::Debug for TargetCommit { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("TargetCommit") - .field("commit_id", &self.commit_id) - .finish() - } -} - -impl TargetCommit { - /// Find `target_commit_id` in the `graph` and store its segment in this instance, or return `None` if not found. - /// The `None` case is acceptable as we consider these possibly stored. - fn from_commit(target_commit_id: gix::ObjectId, graph: &Graph) -> Option { - let segment = graph.segment_by_commit_id(target_commit_id).ok()?; - let commit_index = segment.commit_index_of(target_commit_id)?; - if commit_index != 0 { - tracing::warn!( - current = ?target_commit_id, - segment = ?segment.id, - commit_index, - "Ignoring stored target commit because it is not the first commit in its segment" - ); - return None; - } - Some(TargetCommit { - commit_id: target_commit_id, - segment_index: segment.id, - }) - } -} - -impl TargetRef { - /// Return `None` if `ref_name` wasn't found as segment in `graph`. - /// This can happen if a reference is configured, but not actually present as reference. - /// Note that `commits_ahead` isn't set yet, see [`Self::compute_and_set_commits_ahead()`]. - fn from_ref_name_without_commits_ahead( - ref_name: &gix::refs::FullName, - graph: &Graph, - ) -> Option { - Some(TargetRef { - ref_name: ref_name.to_owned(), - segment_index: graph.segment_by_ref_name(ref_name.as_ref())?.id, - commits_ahead: 0, - }) - } - - fn compute_and_set_commits_ahead( - &mut self, - graph: &Graph, - lower_bound_segment: Option, - ) { - let lower_bound = lower_bound_segment.map(|sidx| (sidx, graph[sidx].generation)); - self.commits_ahead = 0; - Self::visit_upstream_commits(graph, self.segment_index, lower_bound, |s| { - self.commits_ahead += s.commits.len(); - }) - } -} - -fn find_segment_owner_indexes_by_refname( - stacks: &[Stack], - ref_name: &gix::refs::FullNameRef, -) -> Option<(usize, usize)> { - stacks.iter().enumerate().find_map(|(stack_idx, stack)| { - stack - .segments - .iter() - .enumerate() - .find_map(|(seg_idx, seg)| { - seg.ref_name() - .is_some_and(|rn| rn == ref_name) - .then_some((stack_idx, seg_idx)) - }) - }) -} diff --git a/crates/but-graph/src/segment.rs b/crates/but-graph/src/segment.rs deleted file mode 100644 index f673bc81a46..00000000000 --- a/crates/but-graph/src/segment.rs +++ /dev/null @@ -1,462 +0,0 @@ -use bitflags::bitflags; -use bstr::{BString, ByteSlice}; -use but_core::ref_metadata::MaybeDebug; - -use crate::{CommitIndex, Graph, SegmentIndex}; - -/// A commit with must useful information extracted from the Git commit itself. -#[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, -} - -impl Commit { - /// Return an iterator over all reference names that point to this commit. - pub fn ref_name_iter(&self) -> impl Iterator + Clone { - self.refs.iter().map(|ri| &ri.ref_name) - } - - /// Return information about the reference that matches `name`. - pub fn ref_by_name(&self, name: &gix::refs::FullNameRef) -> Option<&RefInfo> { - self.refs.iter().find(|ri| ri.ref_name.as_ref() == name) - } -} - -/// A structure to inform about a reference which was present at a commit. -#[derive(Debug, Clone, Eq, PartialEq)] -pub struct RefInfo { - /// The name of the reference. - pub ref_name: gix::refs::FullName, - /// The peeled commit id the reference pointed to when the graph was built. - /// - /// This is `None` if the reference was known only by name, for example for - /// unborn branches or synthetic segments created without a resolved ref tip. - /// - /// It's useful if the segment with this ref-info instance doesn't actually - /// own a commit, and can't (always) discover it with [crate::Graph::tip_skip_empty()]. - /// Workspace queries use it as a fallback in - /// [`Workspace::tip_commit_by_segment_id()`](crate::Workspace::tip_commit_by_segment_id). - pub commit_id: Option, - /// If `Some`, provide information about the worktree that checks out the reference at `ref_name`, - /// i.e. its `HEAD` points to `ref_name` directly or indirectly due to chains of . - /// - /// It is `None` if no worktree needs to be updated if this reference is changed. - pub worktree: Option, -} - -/// Describes which kind of worktree is checked out by a [Ref](RefInfo). -#[derive(Debug, Clone, Eq, PartialEq)] -pub enum WorktreeKind { - /// The main worktree, i.e. the primary workspace associated with this repository, is checked out. - /// - /// It cannot be removed. - Main, - /// The identifier of the worktree, which is always `.git/worktrees/`, - /// indicating that this is a linked worktree that can be removed. - LinkedId(BString), -} - -/// Describes which worktree is checked out and how it relates to the repository that produced the graph. -#[derive(Debug, Clone, Eq, PartialEq)] -pub struct Worktree { - /// The kind of worktree that checks out the reference. - pub kind: WorktreeKind, - /// The repository that produced the graph is using this worktree. - /// - /// Only one worktree in a graph should have this flag set. - pub owned_by_repo: bool, -} - -impl Worktree { - /// Produce a string that identifies this instance concisely, and visually distinguishable. - /// `ref_name` is the name from the [`RefInfo`] that owns this worktree, - /// used to deduplicate the name we chose. - /// - /// For example, `refs/heads/foo` in linked worktree id `foo` prints - /// `foo[๐Ÿ“]`, not `foo[๐Ÿ“foo]`. - pub fn debug_string(&self, ref_name: &gix::refs::FullNameRef) -> String { - self.debug_string_with_graph_context(ref_name, false) - } - - /// Like [`Self::debug_string()`], but includes graph-contextual worktree ownership markers. - pub fn debug_string_with_graph_context( - &self, - ref_name: &gix::refs::FullNameRef, - show_owned_by_repo: bool, - ) -> String { - let owned_by_repo = if show_owned_by_repo && self.owned_by_repo { - "@repo" - } else { - "" - }; - self.kind.debug_string(ref_name, owned_by_repo) - } -} - -impl WorktreeKind { - fn debug_string(&self, ref_name: &gix::refs::FullNameRef, owned_by_repo: &str) -> String { - match self { - WorktreeKind::Main => format!("[๐ŸŒณ{owned_by_repo}]"), - WorktreeKind::LinkedId(id) => { - format!( - "[๐Ÿ“{id}{owned_by_repo}]", - id = if ref_name.shorten() != id { - id.as_bstr() - } else { - "".into() - } - ) - } - } - } -} - -impl RefInfo { - /// Produce a string that identifies this instance concisely, and visually distinguishable. - pub fn debug_string(&self) -> String { - let ws = self - .worktree - .as_ref() - .map(|ws| ws.debug_string(self.ref_name.as_ref())) - .unwrap_or_default(); - format!("โ–บ{}{ws}", self.ref_name.shorten()) - } -} - -impl std::fmt::Debug for Commit { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let refs = self - .refs - .iter() - .map(|ri| ri.debug_string()) - .collect::>() - .join(", "); - write!( - f, - "Commit({hash}, {flags}{refs})", - hash = self.id.to_hex_with_len(7), - flags = self.flags.debug_string(None) - ) - } -} - -bitflags! { - /// The reason a segment stops without an outgoing graph edge. - /// - /// Multiple flags can be present at once if multiple conditions apply to the same commit. - #[derive(Default, Debug, Copy, Clone, Eq, PartialEq)] - pub struct StopCondition: u8 { - /// Traversal stopped before following parents due to configured traversal limits. - /// - /// If this was a *hard* limit, the graph may not contain all the *interesting* portions of the commit-graph, - /// see [`hard_limit`](crate::init::Options::hard_limit) - const Limit = 1 << 0; - /// Traversal reached the first commit in history, which has no parents, and is an orphan. - /// There can be more than one in one graph if unrelated histories were merged. - const FirstCommit = 1 << 1; - /// Traversal reached a Git shallow boundary, as is created with the shallow clone feature. - const ShallowBoundary = 1 << 2; - } -} - -impl StopCondition { - /// Return a concise symbolic representation of this stop condition for debug output. - pub fn debug_string(&self, hard_limit: bool) -> String { - let mut out = String::new(); - if self.contains(StopCondition::Limit) { - out.push_str(if hard_limit { "โŒ" } else { "โœ‚" }); - } - if self.contains(StopCondition::FirstCommit) { - out.push('๐Ÿ'); - } - if self.contains(StopCondition::ShallowBoundary) { - out.push('โ›ฐ'); - } - out - } - - /// Return `true` if traversal stopped because the configured traversal limit was reached. - pub fn at_limit(&self) -> bool { - self.contains(StopCondition::Limit) - } - - /// Return `true` if traversal stopped due to an artificial boundary, not because history naturally ended. - /// - /// This also means that the traversal would have continued otherwise. - pub fn is_unnatural(&self) -> bool { - self.intersects(StopCondition::Limit | StopCondition::ShallowBoundary) - } -} - -bitflags! { - /// Flags used temporarily during merge-base computation on segments. - /// - /// These flags are cleared before each merge-base computation and should not be persisted. - #[derive(Default, Debug, Copy, Clone, Eq, PartialEq)] - pub struct SegmentFlags: u8 { - /// The segment is reachable from the first input. - const SEGMENT1 = 1 << 0; - /// The segment is reachable from the second input (or any of "others"). - const SEGMENT2 = 1 << 1; - /// The segment has been marked as a potential merge-base result. - const RESULT = 1 << 2; - /// The segment should be skipped in further traversal (already processed or redundant). - const STALE = 1 << 3; - } -} - -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: 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, - /// a property we don't want for this trait. - const NotInRemote = 1 << 0; - /// Following the graph upward will lead to at least one tip that is a workspace. - /// - /// Note that if this flag isn't present, this means the commit isn't reachable - /// from a workspace. - const InWorkspace = 1 << 1; - /// The commit is reachable from either the target branch (usually `refs/remotes/origin/main`). - /// Note that when multiple workspaces are included in the traversal, this flag is set by - /// any of many target branches. - const Integrated = 1 << 2; - /// The commit is listed in the repository's shallow boundary file. - const ShallowBoundary = 1 << 3; - } -} - -impl CommitFlags { - /// The amount of goals that were tracked, i.e. 0 if there is no goal, or N if there are N goal. - pub fn num_goals(&self) -> usize { - let goals = self.bits() & !Self::all().bits(); - if goals == 0 { - 0 - } else { - (Self::all().bits().leading_zeros() - goals.leading_zeros()) as usize - } - } - /// Return a less verbose debug string, with `max_goals` marking the highest amount of goals we have to display. - pub fn debug_string(&self, max_goals: Option) -> String { - if self.is_empty() { - "".into() - } else { - 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()..]; - let mut out = out[..out.len() - 1] - .to_string() - .replace("NotInRemote", "โŒ‚") - .replace("InWorkspace", "๐Ÿ˜") - .replace("Integrated", "โœ“") - .replace("ShallowBoundary", "โ›ฐ") - .replace(" ", ""); - if extra != 0 { - out.push_str(&format!( - "|{extra:>0width$b}", - width = max_goals.unwrap_or(0) - )); - } - 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.contains(CommitFlags::NotInRemote) - } -} - -/// A segment of a commit graph, representing a set of commits exclusively. -#[derive(Default, Clone, Eq, PartialEq)] -pub struct Segment { - /// 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: SegmentIndex, - /// A non-null number, and starting at `1`, to indicate how high up the segment is in the graph past the root nodes. - /// Thus, higher numbers mean they are further down. - /// If `0`, this is a root node, i.e. one without any incoming connections. - pub generation: usize, - /// The unambiguous or disambiguated name of the branch *or tag* at the tip of the segment, i.e. at the first commit, - /// along with its worktree if one happens to point at it. - /// - /// Even though most of the time this will be local branches, when setting the entrypoint onto a commit with a *tag*, - /// it will be used for naming it. - /// - /// 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_info: Option, - /// 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, - /// If `remote_tracking_ref_name` is `None`, and `ref_name` is a remote tracking branch, then this is set to be - /// the segment id of the local tracking branch, effectively doubly-linking them for ease of traversal. - /// If `ref_name` is `None` and this segment is the ancestor of a named segment that is known to a workspace, - /// this id is pointing to that named segment to allow the reconstruction of the originally desired workspace. - pub sibling_segment_id: Option, - /// If `remote_tracking_ref_name` is set, this field is also set to make accessing the respective segment easy, - /// avoiding a search through the entire graph. - /// It *only* ever points to the remote tracking branch segment. - pub remote_tracking_branch_segment_id: 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, - /// Read-only metadata with additional information, or `None` if nothing was present. - pub metadata: Option, -} - -/// Metadata for segments, which are either dedicated branches or represent workspaces. -#[derive(Debug, Clone, Eq, PartialEq)] -pub enum SegmentMetadata { - /// [Segments](Segment) with this data are considered a branch in the workspace. - Branch(but_core::ref_metadata::Branch), - /// [Segments](Segment) with this data are considered the tip of the workspace. - Workspace(but_core::ref_metadata::Workspace), -} - -/// Direct Access (without graph) -impl Segment { - /// Return the name of the reference that points to the first commit reachable through this segment. - pub fn ref_name(&self) -> Option<&gix::refs::FullNameRef> { - self.ref_info.as_ref().map(|ri| ri.ref_name.as_ref()) - } - - /// Return the segment that this segment treats as its sibling, if any. - /// - /// A sibling is a paired segment that represents the same logical ref - /// relationship from another side of the graph. For remote-tracking - /// segments, this points back to the local branch segment configured to - /// track them, such as `refs/remotes/origin/main` pointing to - /// `refs/heads/main`. For anonymous workspace-reconstruction segments, this - /// can point to the named segment that gives the otherwise anonymous segment - /// its intended workspace identity. - /// - /// The sibling id is graph-local, so the lookup must happen in the `graph` - /// that owns this segment. - pub fn sibling_segment<'graph>(&self, graph: &'graph Graph) -> Option<&'graph Segment> { - graph.inner.node_weight(self.sibling_segment_id?) - } - - /// Return the top-most commit id of the segment. - pub fn tip(&self) -> Option { - self.commits.first().map(|commit| commit.id) - } - - /// Return the index of the last (present) commit, or `None` if there is no commit stored in this segment. - pub fn last_commit_index(&self) -> Option { - self.commits.len().checked_sub(1) - } - - /// Try to find the index of `id` in our list of local commits. - pub fn commit_index_of(&self, id: gix::ObjectId) -> Option { - self.commits - .iter() - .enumerate() - .find_map(|(cidx, c)| (c.id == id).then_some(cidx)) - } - - /// Find the commit associated with the given `commit_index`, which for convenience is optional. - pub fn commit_id_by_index(&self, idx: Option) -> Option { - self.commits.get(idx?).map(|c| c.id) - } - - /// Find the commit with the given `id` in the commits of this segment. - pub fn commit_by_id(&self, id: gix::ObjectId) -> Option<&Commit> { - self.commits.iter().find(|c| c.id == id) - } - - /// Return the flags of the first commit if non-empty, which is the top-most commit in the stack assuming - /// it grows upwards into the future. - pub fn non_empty_flags_of_first_commit(&self) -> Option { - let commit = self.commits.first()?; - (!commit.flags.is_empty()).then_some(commit.flags) - } - - /// Return `Some(md)` if this segment contains workspace metadata, which makes it governing a workspace. - /// - /// Note that we assume that this kind of metadata is only assigned to portions of the graph which don't include - /// each other *outside* of integrated portions of the graph, i.e. workspaces can't be nested. - pub fn workspace_metadata(&self) -> Option<&but_core::ref_metadata::Workspace> { - self.metadata.as_ref().and_then(|md| match md { - SegmentMetadata::Workspace(md) => Some(md), - _ => None, - }) - } -} - -impl std::fmt::Debug for Segment { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if f.alternate() { - let Segment { - ref_info, - generation, - id, - commits, - remote_tracking_ref_name, - sibling_segment_id, - remote_tracking_branch_segment_id, - metadata, - } = self; - f.debug_struct("Segment") - .field("id", id) - .field("generation", generation) - .field( - "ref_info", - &MaybeDebug(&ref_info.as_ref().map(|ri| ri.debug_string())), - ) - .field( - "remote_tracking_ref_name", - &MaybeDebug(&remote_tracking_ref_name.as_ref().map(|n| n.to_string())), - ) - .field( - "sibling_segment_id", - &MaybeDebug(&sibling_segment_id.as_ref().map(|id| id.index().to_string())), - ) - .field( - "remote_tracking_branch_segment_id", - &MaybeDebug( - &remote_tracking_branch_segment_id - .as_ref() - .map(|id| id.index().to_string()), - ), - ) - .field("commits", &commits) - .field( - "metadata", - match metadata { - None => &"None", - Some(SegmentMetadata::Branch(m)) => m, - Some(SegmentMetadata::Workspace(m)) => m, - }, - ) - .finish() - } else { - f.debug_struct( - "StackSegment(empty for 'dot' program to not get past 2^16 max label size)", - ) - .finish() - } - } -} diff --git a/crates/but-graph/src/statistics.rs b/crates/but-graph/src/statistics.rs deleted file mode 100644 index 6bed3d24131..00000000000 --- a/crates/but-graph/src/statistics.rs +++ /dev/null @@ -1,196 +0,0 @@ -use petgraph::Direction; - -use crate::{ - CommitFlags, CommitIndex, Graph, SegmentIndex, SegmentMetadata, init::types::TopoWalk, -}; - -impl Graph { - /// 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, - entrypoint, - segment_entrypoint_incoming, - segment_entrypoint_outgoing, - top_segments, - segments_at_bottom, - connections, - commits, - commit_references, - commits_at_cutoff, - } = &mut out; - - *segments = self.inner.node_count(); - *connections = self.inner.edge_count(); - *top_segments = self - .tip_segments() - .map(|s| { - let s = &self[s]; - ( - s.ref_info.clone().map(|ri| ri.ref_name), - s.id, - s.non_empty_flags_of_first_commit(), - ) - }) - .collect(); - *segments_at_bottom = self.base_segments().count(); - *entrypoint = self.entrypoint_location().unwrap_or_default(); - - if let Ok(ep) = self.entrypoint() { - *entrypoint_in_workspace = ep - .segment - .commits - .first() - .map(|c| c.flags.contains(CommitFlags::InWorkspace)); - *segment_entrypoint_incoming = self - .inner - .edges_directed(ep.segment.id, Direction::Incoming) - .count(); - *segment_entrypoint_outgoing = self - .inner - .edges_directed(ep.segment.id, Direction::Outgoing) - .count(); - 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 = - TopoWalk::start_from(ep.segment.id, start_cidx, direction).skip_tip_segment(); - while walk.next(&self.inner).is_some() { - *storage += 1; - } - } - } - - for n in self.inner.node_indices().map(|n| &self[n]) { - *commits += n.commits.len(); - - if n.ref_info.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_remote() { - *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.stop_condition(sidx) - .is_some_and(|condition| condition.is_unnatural()), - ); - } - out - } -} - -/// 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, 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, - /// 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. - 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, -} diff --git a/crates/but-graph/src/utils.rs b/crates/but-graph/src/utils.rs deleted file mode 100644 index 344c0b0678c..00000000000 --- a/crates/but-graph/src/utils.rs +++ /dev/null @@ -1,263 +0,0 @@ -use std::collections::VecDeque; - -use petgraph::{Direction, visit::NodeIndexable}; - -use crate::{Graph, Segment, SegmentIndex}; - -impl Graph { - pub(crate) fn seen_table(&self) -> SeenTable { - SeenTable::new(self.inner.node_bound()) - } -} - -/// Fixed-size storage for tracking visited segments during graph walks. -/// -/// Unlike [`SegmentTable`], this type only represents one concept: -/// whether a segment was seen in the current walk. It is intentionally static: -/// create it after the graph shape is known, and don't use it for segments -/// inserted after construction. -pub(crate) struct SeenTable { - values: Vec, -} - -impl SeenTable { - /// Create a table with space for `num_segments` segment indices. - fn new(num_segments: usize) -> Self { - SeenTable { - values: vec![false; num_segments], - } - } - - /// Insert `sidx` into the seen set if it wasn't present yet. - /// - /// Returns `true` if `sidx` was unseen before this call. - pub(crate) fn insert_unseen(&mut self, sidx: SegmentIndex) -> bool { - let value = &mut self.values[sidx.index()]; - if *value { - return false; - } - *value = true; - true - } -} - -/// Scratch storage keyed directly by [`SegmentIndex`]. -/// -/// Segment indices are dense `petgraph::NodeIndex` values while a graph is alive, -/// so a vector indexed by `SegmentIndex::index()` is a perfect lookup table and -/// avoids hash-map overhead in hot graph algorithms. This table is fixed-size: -/// create it after the graph shape is known and use it only while no newly -/// inserted segment can be addressed through it. -/// -/// Instead of clearing the whole vector between phases, it remembers which slots -/// changed away from `empty` and resets only those slots. This keeps reuse cheap -/// for walks that touch a small part of a large graph. -pub(crate) struct SegmentTable { - values: Vec, - touched: Vec, - empty: T, -} - -impl SegmentTable { - /// Create a fixed-size table with one slot for every segment index currently - /// representable by `node_bound`. - pub(crate) fn new(node_bound: usize, empty: T) -> Self { - SegmentTable { - values: vec![empty; node_bound], - touched: Vec::new(), - empty, - } - } - - /// Reset all slots touched since the last clear back to the `empty` value. - pub(crate) fn clear(&mut self) { - for sidx in self.touched.drain(..) { - self.values[sidx.index()] = self.empty; - } - } - - /// Return the value stored for `sidx`. - pub(crate) fn get(&self, sidx: SegmentIndex) -> T { - self.values[sidx.index()] - } - - /// Return a mutable slot for `sidx`, marking it for later clearing if it is - /// currently `empty`. - pub(crate) fn get_mut(&mut self, sidx: SegmentIndex) -> &mut T { - let index = sidx.index(); - if self.values[index] == self.empty { - self.touched.push(sidx); - } - &mut self.values[index] - } - - /// Set `sidx` to `value`. - /// - /// Use this when the caller already knows the slot is empty or doesn't need - /// to know whether it changed. - pub(crate) fn set(&mut self, sidx: SegmentIndex, value: T) { - let index = sidx.index(); - if self.values[index] == self.empty { - self.touched.push(sidx); - } - self.values[index] = value; - } - - /// Set `sidx` to `value` only if it is still `empty`. - /// - /// Returns `true` if the slot changed. This is useful for visited sets. - pub(crate) fn set_if_empty(&mut self, sidx: SegmentIndex, value: T) -> bool { - let index = sidx.index(); - if self.values[index] != self.empty { - return false; - } - self.touched.push(sidx); - self.values[index] = value; - true - } -} - -/// A [`SegmentTable`] that can accommodate segments inserted after construction. -/// -/// Most algorithms should prefer [`SegmentTable`] because fixed-size direct -/// indexing makes invalid usage obvious. This wrapper is for longer-lived -/// scratch state in post-processing, where the graph may grow while the scratch -/// table is still reused. It preserves the same touched-slot clearing behavior, -/// but grows before accessing an out-of-range segment index. -pub(crate) struct GrowingSegmentTable { - inner: SegmentTable, -} - -impl GrowingSegmentTable { - /// Create a growable table initially sized for the current graph `node_bound`. - pub(crate) fn new(node_bound: usize, empty: T) -> Self { - GrowingSegmentTable { - inner: SegmentTable::new(node_bound, empty), - } - } - - /// Reset all touched slots back to the `empty` value. - pub(crate) fn clear(&mut self) { - self.inner.clear(); - } - - /// Set `sidx` to `value`, growing first if necessary. - pub(crate) fn set(&mut self, sidx: SegmentIndex, value: T) { - self.ensure_index(sidx); - self.inner.set(sidx, value); - } - - /// Set `sidx` to `value` only if it is still `empty`, growing first if - /// necessary. - pub(crate) fn set_if_empty(&mut self, sidx: SegmentIndex, value: T) -> bool { - self.ensure_index(sidx); - self.inner.set_if_empty(sidx, value) - } - - /// Ensure `sidx` is addressable by the wrapped table. - fn ensure_index(&mut self, sidx: SegmentIndex) { - let index = sidx.index(); - if index >= self.inner.values.len() { - self.inner.values.resize(index + 1, self.inner.empty); - } - } -} - -/// Reusable scratch state for repeated segment graph walks during post-processing. -/// -/// Workspace post-processing may run many short traversals while also inserting -/// new segments into the graph. Reusing the queue and visited table avoids -/// allocating a fresh visited set for every walk, and the growable table keeps -/// the scratch state valid if post-processing creates segment indices that did -/// not exist when the scratch state was created. -pub(crate) struct SegmentVisitScratch { - seen: GrowingSegmentTable, - next: VecDeque, -} - -impl SegmentVisitScratch { - /// Create scratch storage initially sized for the current graph. - pub(crate) fn new(graph: &Graph) -> Self { - SegmentVisitScratch { - seen: GrowingSegmentTable::new(graph.inner.node_bound(), false), - next: VecDeque::new(), - } - } - - /// Visit `start` and all reachable segments in `graph` until `visit_and_prune` returns - /// `true` for a segment. - /// - /// `visit_and_prune` receives each visited segment. Returning `true` stops - /// traversal through that segment, pruning its parents or children depending - /// on `direction`; returning `false` continues traversal through its - /// neighbors. - pub(crate) fn visit_including_start_until( - &mut self, - graph: &Graph, - start: SegmentIndex, - direction: Direction, - mut visit_and_prune: impl FnMut(&Segment) -> bool, - ) { - self.visit_until(graph, start, direction, true, &mut visit_and_prune); - } - - /// Visit all reachable segments in `graph` below or above `start`, excluding `start` - /// from the callback. - /// - /// `visit_and_prune` receives each visited segment except `start`. - /// Returning `true` stops traversal through that segment, pruning its - /// parents or children depending on `direction`; returning `false` - /// continues traversal through its neighbors. - /// - /// `start` is still marked as seen so it cannot be requeued through a cycle - /// or cross-edge. - pub(crate) fn visit_excluding_start_until( - &mut self, - graph: &Graph, - start: SegmentIndex, - direction: Direction, - mut visit_and_prune: impl FnMut(&Segment) -> bool, - ) { - self.visit_until(graph, start, direction, false, &mut visit_and_prune); - } - - /// Shared breadth-first traversal implementation. - fn visit_until( - &mut self, - graph: &Graph, - start: SegmentIndex, - direction: Direction, - include_start: bool, - visit_and_prune: &mut impl FnMut(&Segment) -> bool, - ) { - self.reset(); - self.mark_known_unseen(start); - self.next.push_back(start); - - while let Some(next_sidx) = self.next.pop_front() { - if (!include_start && start == next_sidx) || !visit_and_prune(&graph[next_sidx]) { - for neighbor in graph.inner.neighbors_directed(next_sidx, direction) { - if self.mark(neighbor) { - self.next.push_back(neighbor); - } - } - } - } - } - - /// Clear only the state touched by the previous walk. - fn reset(&mut self) { - self.next.clear(); - self.seen.clear(); - } - - /// Mark `sidx` if it hasn't been seen in the current walk. - fn mark(&mut self, sidx: SegmentIndex) -> bool { - self.seen.set_if_empty(sidx, true) - } - - /// Mark `sidx` when the caller already knows it hasn't been seen. - fn mark_known_unseen(&mut self, sidx: SegmentIndex) { - self.seen.set(sidx, true); - } -} diff --git a/crates/but-graph/tests/graph/init/overlay.rs b/crates/but-graph/tests/graph/init/overlay.rs deleted file mode 100644 index 9e56518a683..00000000000 --- a/crates/but-graph/tests/graph/init/overlay.rs +++ /dev/null @@ -1,388 +0,0 @@ -//! Some tests that explicitly test the overlay functionality - -use but_graph::{Graph, init::Overlay}; -use but_testsupport::{graph_tree, visualize_commit_graph_all}; -use snapbox::IntoData; - -use crate::init::{read_only_in_memory_scenario, standard_options}; - -#[test] -fn drop_and_add_regular_refs() -> anyhow::Result<()> { - let (repo, meta) = read_only_in_memory_scenario("four-diamond")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 8a6c109 (HEAD -> merged) Merge branch 'C' into merged -|\ -| * 7ed512a (C) Merge branch 'D' into C -| |\ -| | * ecb1877 (D) D -| * | 35ee481 C -| |/ -* | 62b409a (A) Merge branch 'B' into A -|\ \ -| * | f16dddf (B) B -| |/ -* / 592abec A -|/ -* 965998b (main) base - -"#]] - .raw() - ); - - let graph = Graph::from_head( - &repo, - &*meta, - but_core::ref_metadata::ProjectMeta::default(), - standard_options(), - )?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:merged[๐ŸŒณ] - โ””โ”€โ”€ ยท8a6c109 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:A - โ”‚ โ””โ”€โ”€ ยท62b409a (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ โ–บ:3[2]:anon: - โ”‚ โ”‚ โ””โ”€โ”€ ยท592abec (โŒ‚|1) - โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:7[3]:main - โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท965998b (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[2]:B - โ”‚ โ””โ”€โ”€ ยทf16dddf (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:7: (main) - โ””โ”€โ”€ โ–บ:2[1]:C - โ””โ”€โ”€ ยท7ed512a (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:5[2]:anon: - โ”‚ โ””โ”€โ”€ ยท35ee481 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:7: (main) - โ””โ”€โ”€ โ–บ:6[2]:D - โ””โ”€โ”€ ยทecb1877 (โŒ‚|1) - โ””โ”€โ”€ โ†’:7: (main) - -"#]] - ); - - let to_reference = repo.rev_parse_single("35ee481")?; - - let overlay = Overlay::default() - .with_references([gix::refs::Reference { - name: "refs/heads/new-reference".try_into()?, - target: gix::refs::Target::Object(to_reference.detach()), - peeled: Some(to_reference.detach()), - }]) - .with_dropped_references(["refs/heads/C".try_into()?]); - - let graph = graph.redo_traversal_with_overlay(&repo, &*meta, overlay)?; - - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:merged[๐ŸŒณ] - โ””โ”€โ”€ ยท8a6c109 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:A - โ”‚ โ””โ”€โ”€ ยท62b409a (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ โ–บ:3[2]:anon: - โ”‚ โ”‚ โ””โ”€โ”€ ยท592abec (โŒ‚|1) - โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:7[3]:main - โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท965998b (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[2]:B - โ”‚ โ””โ”€โ”€ ยทf16dddf (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:7: (main) - โ””โ”€โ”€ โ–บ:2[1]:anon: - โ””โ”€โ”€ ยท7ed512a (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:5[2]:new-reference - โ”‚ โ””โ”€โ”€ ยท35ee481 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:7: (main) - โ””โ”€โ”€ โ–บ:6[2]:D - โ””โ”€โ”€ ยทecb1877 (โŒ‚|1) - โ””โ”€โ”€ โ†’:7: (main) - -"#]] - ); - - Ok(()) -} - -#[test] -fn drop_head_ref() -> anyhow::Result<()> { - let (repo, meta) = read_only_in_memory_scenario("four-diamond")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 8a6c109 (HEAD -> merged) Merge branch 'C' into merged -|\ -| * 7ed512a (C) Merge branch 'D' into C -| |\ -| | * ecb1877 (D) D -| * | 35ee481 C -| |/ -* | 62b409a (A) Merge branch 'B' into A -|\ \ -| * | f16dddf (B) B -| |/ -* / 592abec A -|/ -* 965998b (main) base - -"#]] - .raw() - ); - - let graph = Graph::from_head( - &repo, - &*meta, - but_core::ref_metadata::ProjectMeta::default(), - standard_options(), - )?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:merged[๐ŸŒณ] - โ””โ”€โ”€ ยท8a6c109 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:A - โ”‚ โ””โ”€โ”€ ยท62b409a (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ โ–บ:3[2]:anon: - โ”‚ โ”‚ โ””โ”€โ”€ ยท592abec (โŒ‚|1) - โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:7[3]:main - โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท965998b (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[2]:B - โ”‚ โ””โ”€โ”€ ยทf16dddf (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:7: (main) - โ””โ”€โ”€ โ–บ:2[1]:C - โ””โ”€โ”€ ยท7ed512a (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:5[2]:anon: - โ”‚ โ””โ”€โ”€ ยท35ee481 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:7: (main) - โ””โ”€โ”€ โ–บ:6[2]:D - โ””โ”€โ”€ ยทecb1877 (โŒ‚|1) - โ””โ”€โ”€ โ†’:7: (main) - -"#]] - ); - - let overlay = Overlay::default().with_dropped_references(["refs/heads/merged".try_into()?]); - - let graph = graph.redo_traversal_with_overlay(&repo, &*meta, overlay)?; - - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ โ–บ:0[0]:anon: - โ””โ”€โ”€ ๐Ÿ‘‰ยท8a6c109 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:A - โ”‚ โ””โ”€โ”€ ยท62b409a (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ โ–บ:3[2]:anon: - โ”‚ โ”‚ โ””โ”€โ”€ ยท592abec (โŒ‚|1) - โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:7[3]:main - โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท965998b (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[2]:B - โ”‚ โ””โ”€โ”€ ยทf16dddf (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:7: (main) - โ””โ”€โ”€ โ–บ:2[1]:C - โ””โ”€โ”€ ยท7ed512a (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:5[2]:anon: - โ”‚ โ””โ”€โ”€ ยท35ee481 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:7: (main) - โ””โ”€โ”€ โ–บ:6[2]:D - โ””โ”€โ”€ ยทecb1877 (โŒ‚|1) - โ””โ”€โ”€ โ†’:7: (main) - -"#]] - ); - - Ok(()) -} - -#[test] -fn overriding_references() -> anyhow::Result<()> { - let (repo, meta) = read_only_in_memory_scenario("four-diamond")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 8a6c109 (HEAD -> merged) Merge branch 'C' into merged -|\ -| * 7ed512a (C) Merge branch 'D' into C -| |\ -| | * ecb1877 (D) D -| * | 35ee481 C -| |/ -* | 62b409a (A) Merge branch 'B' into A -|\ \ -| * | f16dddf (B) B -| |/ -* / 592abec A -|/ -* 965998b (main) base - -"#]] - .raw() - ); - - let graph = Graph::from_head( - &repo, - &*meta, - but_core::ref_metadata::ProjectMeta::default(), - standard_options(), - )?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:merged[๐ŸŒณ] - โ””โ”€โ”€ ยท8a6c109 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:A - โ”‚ โ””โ”€โ”€ ยท62b409a (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ โ–บ:3[2]:anon: - โ”‚ โ”‚ โ””โ”€โ”€ ยท592abec (โŒ‚|1) - โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:7[3]:main - โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท965998b (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[2]:B - โ”‚ โ””โ”€โ”€ ยทf16dddf (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:7: (main) - โ””โ”€โ”€ โ–บ:2[1]:C - โ””โ”€โ”€ ยท7ed512a (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:5[2]:anon: - โ”‚ โ””โ”€โ”€ ยท35ee481 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:7: (main) - โ””โ”€โ”€ โ–บ:6[2]:D - โ””โ”€โ”€ ยทecb1877 (โŒ‚|1) - โ””โ”€โ”€ โ†’:7: (main) - -"#]] - ); - - let merged_a = repo.rev_parse_single("35ee481")?; - let merged_b = repo.rev_parse_single("592abec")?; - let merged: gix::refs::FullName = "refs/heads/merged".try_into()?; - - // The dropped takes precedence over git or overriding references. - let overlay = Overlay::default() - .with_dropped_references([merged.clone()]) - .with_references([ - gix::refs::Reference { - name: merged.clone(), - target: gix::refs::Target::Object(merged_a.detach()), - peeled: Some(merged_a.detach()), - }, - gix::refs::Reference { - name: merged.clone(), - target: gix::refs::Target::Object(merged_b.detach()), - peeled: Some(merged_b.detach()), - }, - ]); - - let graph = graph.redo_traversal_with_overlay(&repo, &*meta, overlay)?; - - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ โ–บ:0[0]:anon: - โ””โ”€โ”€ ๐Ÿ‘‰ยท8a6c109 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:A - โ”‚ โ””โ”€โ”€ ยท62b409a (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ โ–บ:3[2]:anon: - โ”‚ โ”‚ โ””โ”€โ”€ ยท592abec (โŒ‚|1) - โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:7[3]:main - โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท965998b (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[2]:B - โ”‚ โ””โ”€โ”€ ยทf16dddf (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:7: (main) - โ””โ”€โ”€ โ–บ:2[1]:C - โ””โ”€โ”€ ยท7ed512a (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:5[2]:anon: - โ”‚ โ””โ”€โ”€ ยท35ee481 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:7: (main) - โ””โ”€โ”€ โ–บ:6[2]:D - โ””โ”€โ”€ ยทecb1877 (โŒ‚|1) - โ””โ”€โ”€ โ†’:7: (main) - -"#]] - ); - - // The first overriding reference precedence over git or other overriding references. - let overlay = Overlay::default().with_references([ - gix::refs::Reference { - name: merged.clone(), - target: gix::refs::Target::Object(merged_a.detach()), - peeled: Some(merged_a.detach()), - }, - gix::refs::Reference { - name: merged.clone(), - target: gix::refs::Target::Object(merged_b.detach()), - peeled: Some(merged_b.detach()), - }, - ]); - - let graph = graph.redo_traversal_with_overlay(&repo, &*meta, overlay)?; - - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ โ–บ:0[0]:anon: - โ””โ”€โ”€ ๐Ÿ‘‰ยท8a6c109 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:A - โ”‚ โ””โ”€โ”€ ยท62b409a (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ โ–บ:3[2]:anon: - โ”‚ โ”‚ โ””โ”€โ”€ ยท592abec (โŒ‚|1) - โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:7[3]:main - โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท965998b (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[2]:B - โ”‚ โ””โ”€โ”€ ยทf16dddf (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:7: (main) - โ””โ”€โ”€ โ–บ:2[1]:C - โ””โ”€โ”€ ยท7ed512a (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:5[2]:merged[๐ŸŒณ] - โ”‚ โ””โ”€โ”€ ยท35ee481 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:7: (main) - โ””โ”€โ”€ โ–บ:6[2]:D - โ””โ”€โ”€ ยทecb1877 (โŒ‚|1) - โ””โ”€โ”€ โ†’:7: (main) - -"#]] - ); - - // overriding references take precedence over git. - let overlay = Overlay::default().with_references([gix::refs::Reference { - name: merged.clone(), - target: gix::refs::Target::Object(merged_b.detach()), - peeled: Some(merged_b.detach()), - }]); - - let graph = graph.redo_traversal_with_overlay(&repo, &*meta, overlay)?; - - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ โ–บ:0[0]:anon: - โ””โ”€โ”€ ๐Ÿ‘‰ยท8a6c109 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:A - โ”‚ โ””โ”€โ”€ ยท62b409a (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ โ–บ:3[2]:merged[๐ŸŒณ] - โ”‚ โ”‚ โ””โ”€โ”€ ยท592abec (โŒ‚|1) - โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:7[3]:main - โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท965998b (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[2]:B - โ”‚ โ””โ”€โ”€ ยทf16dddf (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:7: (main) - โ””โ”€โ”€ โ–บ:2[1]:C - โ””โ”€โ”€ ยท7ed512a (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:5[2]:anon: - โ”‚ โ””โ”€โ”€ ยท35ee481 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:7: (main) - โ””โ”€โ”€ โ–บ:6[2]:D - โ””โ”€โ”€ ยทecb1877 (โŒ‚|1) - โ””โ”€โ”€ โ†’:7: (main) - -"#]] - ); - - Ok(()) -} diff --git a/crates/but-graph/tests/graph/init/with_workspace.rs b/crates/but-graph/tests/graph/init/with_workspace.rs deleted file mode 100644 index 34ca68838af..00000000000 --- a/crates/but-graph/tests/graph/init/with_workspace.rs +++ /dev/null @@ -1,9344 +0,0 @@ -use but_core::{ - RefMetadata, WORKSPACE_REF_NAME, - ref_metadata::{ - ProjectMeta, StackId, WorkspaceCommitRelation, WorkspaceStack, WorkspaceStackBranch, - }, -}; -use but_graph::{ - Graph, SegmentMetadata, - init::{Overlay, Tip, TipRole}, -}; -use but_testsupport::{ - InMemoryRefMetadata, graph_tree, graph_workspace, visualize_commit_graph_all, -}; -use snapbox::prelude::*; - -use crate::init::{ - StackState, add_stack_with_segments, add_workspace, id_at, id_by_rev, - read_only_in_memory_scenario, standard_options, - utils::{ - add_stack, add_workspace_with_target, add_workspace_without_target, - named_read_only_in_memory_scenario, remove_target, standard_options_with_extra_target, - }, -}; - -fn project_meta(meta: &impl RefMetadata) -> ProjectMeta { - meta.workspace(WORKSPACE_REF_NAME.try_into().expect("valid workspace ref")) - .map(|workspace| workspace.project_meta()) - .unwrap_or_default() -} - -#[test] -fn workspace_with_stack_and_local_target() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/local-target-and-stack")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 59a427f (HEAD -> gitbutler/workspace) GitButler Workspace Commit -|\ -| * a62b0de (A) A2 -| * 120a217 A1 -* | 0a415d8 (main) M3 -| | * 1f5c47b (origin/main) RM1 -| |/ -|/| -* | 73ba99d M2 -|/ -* fafd9d0 init - -"#]] - .raw() - ); - - add_workspace(&mut meta); - let graph = Graph::from_head( - &repo, - &*meta, - but_core::ref_metadata::ProjectMeta::default(), - standard_options(), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท59a427f (โŒ‚|๐Ÿ˜|001) -โ”‚ โ”œโ”€โ”€ โ–บ:1[1]:main <> origin/main โ†’:2: -โ”‚ โ”‚ โ””โ”€โ”€ ยท0a415d8 (โŒ‚|๐Ÿ˜|011) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:4[2]:anon: -โ”‚ โ”‚ โ””โ”€โ”€ ยท73ba99d (โŒ‚|๐Ÿ˜|111) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:5[3]:anon: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|111) -โ”‚ โ””โ”€โ”€ โ–บ:3[1]:A -โ”‚ โ”œโ”€โ”€ ยทa62b0de (โŒ‚|๐Ÿ˜|001) -โ”‚ โ””โ”€โ”€ ยท120a217 (โŒ‚|๐Ÿ˜|001) -โ”‚ โ””โ”€โ”€ โ†’:5: -โ””โ”€โ”€ โ–บ:2[0]:origin/main โ†’:1: - โ””โ”€โ”€ ๐ŸŸฃ1f5c47b (0x0|100) - โ””โ”€โ”€ โ†’:4: - -"#]] - ); - - snapbox::assert_data_eq!( - graph - .managed_entrypoint_commit(&repo)? - .expect("this is managed workspace commit") - .to_debug(), - snapbox::str![[r#" -Commit(59a427f, โŒ‚|๐Ÿ˜|1) - -"#]] - ); - - // It's perfectly valid to have the local tracking branch of our target in the workspace, - // and the low-bound computation works as well. - let ws = &graph.into_workspace()?; - snapbox::assert_data_eq!( - graph_workspace(ws).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on fafd9d0 -โ”œโ”€โ”€ โ‰ก:main <> origin/mainโ‡ก1โ‡ฃ1 on fafd9d0 -โ”‚ โ””โ”€โ”€ :main <> origin/mainโ‡ก1โ‡ฃ1 -โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ1f5c47b -โ”‚ โ”œโ”€โ”€ ยท0a415d8 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ โ„๏ธ73ba99d (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก:A on fafd9d0 - โ””โ”€โ”€ :A - โ”œโ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) - -"#]] - ); - - Ok(()) -} - -#[test] -fn workspace_with_only_local_target() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/local-contained-and-target-ahead")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* e5e2623 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -| * cb54dca (origin/main) RM1 -|/ -* 0a415d8 (main) M3 -* 73ba99d M2 -* fafd9d0 init - -"#]] - ); - - add_workspace(&mut meta); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยทe5e2623 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:2[1]:main <> origin/main โ†’:1: -โ”‚ โ”œโ”€โ”€ ยท0a415d8 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ”œโ”€โ”€ ยท73ba99d (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|11) -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ ๐ŸŸฃcb54dca (โœ“) - โ””โ”€โ”€ โ†’:2: (main โ†’:1:) - -"#]] - ); - - let ws = &graph.into_workspace()?; - // It's notable how the local tracking branch of our target (origin/main) is ignored, it's not part of our workspace, - // but acts as base. - snapbox::assert_data_eq!( - graph_workspace(ws).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 0a415d8 - -"#]] - ); - - Ok(()) -} - -#[test] -fn reproduce_11483() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/reproduce-11483")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 3562fcd (HEAD -> gitbutler/workspace) GitButler Workspace Commit -|\ -| * 7236012 (A) A -* | 68c8a9d (B) B -|/ -* 3183e43 (origin/main, main, below) M1 - -"#]] - .raw() - ); - - add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &[]); - add_stack_with_segments(&mut meta, 2, "B", StackState::InWorkspace, &["below"]); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - let ws = &graph.into_workspace()?; - snapbox::assert_data_eq!( - graph_workspace(ws).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท7236012 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {2} - โ”œโ”€โ”€ ๐Ÿ“™:B - โ”‚ โ””โ”€โ”€ ยท68c8a9d (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:below - -"#]] - ); - - meta.data_mut().branches.clear(); - add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &["below"]); - add_stack_with_segments(&mut meta, 2, "B", StackState::InWorkspace, &[]); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {1} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A -โ”‚ โ”‚ โ””โ”€โ”€ ยท7236012 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:below -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {2} - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยท68c8a9d (๐Ÿ˜๏ธ) - -"#]] - ); - - Ok(()) -} - -#[test] -fn workspace_projection_with_advanced_stack_tip() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/advanced-stack-tip-outside-workspace")?; - add_stack_with_segments(&mut meta, 1, "B", StackState::InWorkspace, &["A"]); - - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* cc0bf57 (B) B-outside -| * 2076060 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -|/ -* d69fe94 B -* 09d8e52 (A) A -* 85efbe4 (origin/main, main) M - -"#]] - ); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท2076060 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:5[1]:anon: โ†’:3: -โ”‚ โ””โ”€โ”€ ยทd69fe94 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:4[2]:A -โ”‚ โ””โ”€โ”€ ยท09d8e52 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:2[3]:main <> origin/main โ†’:1: -โ”‚ โ””โ”€โ”€ ๐Ÿยท85efbe4 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”œโ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ โ†’:2: (main โ†’:1:) -โ””โ”€โ”€ ๐Ÿ“™โ–บ:3[0]:B - โ””โ”€โ”€ ยทcc0bf57 (โŒ‚) - โ””โ”€โ”€ โ†’:5: - -"#]] - ); - let ws = &graph.into_workspace()?; - snapbox::assert_data_eq!( - graph_workspace(ws).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {1} - โ”œโ”€โ”€ ๐Ÿ“™:B - โ”‚ โ”œโ”€โ”€ ยทcc0bf57* - โ”‚ โ””โ”€โ”€ ยทd69fe94 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:A - โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) - -"#]] - ); - - Ok(()) -} - -#[test] -fn no_overzealous_stacks_due_to_workspace_metadata() -> anyhow::Result<()> { - // NOTE: Was supposed to reproduce #11459, but it found another issue instead. - let (repo, mut meta) = read_only_in_memory_scenario("ws/reproduce-11459")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 12102a6 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -|\ -| * 0b203b5 (X) X2 -| * 4840f3b (origin/X) X1 -* | 835086d (three, four) W2 -* | ff310d3 W1 -| | * 5e9d772 (origin/two) T1 -| |/ -|/| -* | a821094 (origin/main, two, remote, one, main, feat-2) M3 -* | bce0c5e M2 -|/ -* 3183e43 (A) M1 - -"#]] - .raw() - ); - - add_stack_with_segments(&mut meta, 1, "X", StackState::InWorkspace, &[]); - add_stack_with_segments(&mut meta, 2, "feat-2", StackState::InWorkspace, &[]); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก:anon: on a821094 {2} -โ”‚ โ””โ”€โ”€ :anon: -โ”‚ โ”œโ”€โ”€ ยท835086d (๐Ÿ˜๏ธ) โ–บfour, โ–บthree -โ”‚ โ””โ”€โ”€ ยทff310d3 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:X <> origin/Xโ‡ก1 on 3183e43 {1} - โ””โ”€โ”€ ๐Ÿ“™:X <> origin/Xโ‡ก1 - โ”œโ”€โ”€ ยท0b203b5 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ โ„๏ธ4840f3b (๐Ÿ˜๏ธ) - -"#]] - ); - - Ok(()) -} - -#[test] -fn single_stack_ambiguous() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/single-stack-ambiguous")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 20de6ee (HEAD -> gitbutler/workspace) GitButler Workspace Commit -* 70e9a36 (B) with-ref -* 320e105 (tag: without-ref) segment-B -* 2a31450 (ambiguous-01, B-empty) segment-B~1 -* 70bde6b (origin/B, A-empty-03, A-empty-02, A-empty-01, A) segment-A -* fafd9d0 (origin/main, new-B, new-A, main) init - -"#]] - ); - - // Just a workspace, no additional ref information. - // As the segments are ambiguous, there are many unnamed segments. - add_workspace(&mut meta); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท20de6ee (โŒ‚|๐Ÿ˜|0001) -โ”‚ โ””โ”€โ”€ โ–บ:3[1]:B <> origin/B โ†’:4: -โ”‚ โ”œโ”€โ”€ ยท70e9a36 (โŒ‚|๐Ÿ˜|0101) -โ”‚ โ”œโ”€โ”€ ยท320e105 (โŒ‚|๐Ÿ˜|0101) โ–บtags/without-ref -โ”‚ โ””โ”€โ”€ ยท2a31450 (โŒ‚|๐Ÿ˜|0101) โ–บB-empty, โ–บambiguous-01 -โ”‚ โ””โ”€โ”€ โ–บ:4[2]:origin/B โ†’:3: -โ”‚ โ””โ”€โ”€ ยท70bde6b (โŒ‚|๐Ÿ˜|1101) โ–บA, โ–บA-empty-01, โ–บA-empty-02, โ–บA-empty-03 -โ”‚ โ””โ”€โ”€ โ–บ:2[3]:main <> origin/main โ†’:1: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|1111) โ–บnew-A, โ–บnew-B -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ โ†’:2: (main โ†’:1:) - -"#]] - ); - - // All non-integrated segments are visible. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:B <> origin/Bโ‡ก3 on fafd9d0 - โ””โ”€โ”€ :B <> origin/Bโ‡ก3 - โ”œโ”€โ”€ ยท70e9a36 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) โ–บtags/without-ref - โ”œโ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) โ–บB-empty, โ–บambiguous-01 - โ””โ”€โ”€ โ„๏ธ70bde6b (๐Ÿ˜๏ธ) โ–บA, โ–บA-empty-01, โ–บA-empty-02, โ–บA-empty-03 - -"#]] - ); - - // There is always a segment for the entrypoint, and code working with the graph - // deals with that naturally. - let (without_ref_id, ref_name) = id_at(&repo, "without-ref"); - let graph = Graph::from_commit_traversal( - without_ref_id, - ref_name, - &*meta, - project_meta(&*meta), - standard_options(), - )? - .validated()?; - // See how tags ARE allowed to name a segment, at least when used as entrypoint. - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท20de6ee (โŒ‚|๐Ÿ˜) -โ”‚ โ””โ”€โ”€ โ–บ:4[1]:B <> origin/B โ†’:5: -โ”‚ โ””โ”€โ”€ ยท70e9a36 (โŒ‚|๐Ÿ˜|0100) -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[2]:tags/without-ref -โ”‚ โ”œโ”€โ”€ ยท320e105 (โŒ‚|๐Ÿ˜|0101) -โ”‚ โ””โ”€โ”€ ยท2a31450 (โŒ‚|๐Ÿ˜|0101) โ–บB-empty, โ–บambiguous-01 -โ”‚ โ””โ”€โ”€ โ–บ:6[3]:anon: -โ”‚ โ””โ”€โ”€ ยท70bde6b (โŒ‚|๐Ÿ˜|1101) โ–บA, โ–บA-empty-01, โ–บA-empty-02, โ–บA-empty-03 -โ”‚ โ””โ”€โ”€ โ–บ:3[4]:main <> origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|1111) โ–บnew-A, โ–บnew-B -โ”œโ”€โ”€ โ–บ:2[0]:origin/main โ†’:3: -โ”‚ โ””โ”€โ”€ โ†’:3: (main โ†’:2:) -โ””โ”€โ”€ โ–บ:5[0]:origin/B โ†’:4: - โ””โ”€โ”€ โ†’:6: - -"#]] - ); - // Now `HEAD` is outside a workspace, which goes to single-branch mode. But it knows it's in a workspace - // and shows the surrounding parts, while marking the segment as entrypoint. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:B <> origin/Bโ‡ก1 on fafd9d0 - โ”œโ”€โ”€ :B <> origin/Bโ‡ก1 - โ”‚ โ””โ”€โ”€ ยท70e9a36 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ‘‰:tags/without-ref - โ”œโ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) โ–บB-empty, โ–บambiguous-01 - โ””โ”€โ”€ โ„70bde6b (๐Ÿ˜๏ธ) โ–บA, โ–บA-empty-01, โ–บA-empty-02, โ–บA-empty-03 - -"#]] - ); - - // We don't have to give it a ref-name - let graph = Graph::from_commit_traversal( - without_ref_id, - None, - &*meta, - project_meta(&*meta), - standard_options(), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท20de6ee (โŒ‚|๐Ÿ˜) -โ”‚ โ””โ”€โ”€ โ–บ:4[1]:B <> origin/B โ†’:5: -โ”‚ โ””โ”€โ”€ ยท70e9a36 (โŒ‚|๐Ÿ˜|0100) -โ”‚ โ””โ”€โ”€ โ–บ:0[2]:anon: -โ”‚ โ”œโ”€โ”€ ๐Ÿ‘‰ยท320e105 (โŒ‚|๐Ÿ˜|0101) โ–บtags/without-ref -โ”‚ โ””โ”€โ”€ ยท2a31450 (โŒ‚|๐Ÿ˜|0101) โ–บB-empty, โ–บambiguous-01 -โ”‚ โ””โ”€โ”€ โ–บ:6[3]:anon: -โ”‚ โ””โ”€โ”€ ยท70bde6b (โŒ‚|๐Ÿ˜|1101) โ–บA, โ–บA-empty-01, โ–บA-empty-02, โ–บA-empty-03 -โ”‚ โ””โ”€โ”€ โ–บ:3[4]:main <> origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|1111) โ–บnew-A, โ–บnew-B -โ”œโ”€โ”€ โ–บ:2[0]:origin/main โ†’:3: -โ”‚ โ””โ”€โ”€ โ†’:3: (main โ†’:2:) -โ””โ”€โ”€ โ–บ:5[0]:origin/B โ†’:4: - โ””โ”€โ”€ โ†’:6: - -"#]] - ); - - // Entrypoint is now unnamed (as no ref-name was provided for traversal) - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:B <> origin/Bโ‡ก1 on fafd9d0 - โ”œโ”€โ”€ :B <> origin/Bโ‡ก1 - โ”‚ โ””โ”€โ”€ ยท70e9a36 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ‘‰:anon: - โ”œโ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) โ–บtags/without-ref - โ”œโ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) โ–บB-empty, โ–บambiguous-01 - โ””โ”€โ”€ โ„70bde6b (๐Ÿ˜๏ธ) โ–บA, โ–บA-empty-01, โ–บA-empty-02, โ–บA-empty-03 - -"#]] - ); - - // Putting the entrypoint onto a commit in an anonymous segment with ambiguous refs makes no difference. - let (b_id_1, tag_ref_name) = id_at(&repo, "B-empty"); - let graph = Graph::from_commit_traversal( - b_id_1, - None, - &*meta, - project_meta(&*meta), - standard_options(), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท20de6ee (โŒ‚|๐Ÿ˜) -โ”‚ โ””โ”€โ”€ โ–บ:4[1]:B <> origin/B โ†’:5: -โ”‚ โ”œโ”€โ”€ ยท70e9a36 (โŒ‚|๐Ÿ˜|0100) -โ”‚ โ””โ”€โ”€ ยท320e105 (โŒ‚|๐Ÿ˜|0100) โ–บtags/without-ref -โ”‚ โ””โ”€โ”€ โ–บ:0[2]:anon: -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰ยท2a31450 (โŒ‚|๐Ÿ˜|0101) โ–บB-empty, โ–บambiguous-01 -โ”‚ โ””โ”€โ”€ โ–บ:6[3]:anon: -โ”‚ โ””โ”€โ”€ ยท70bde6b (โŒ‚|๐Ÿ˜|1101) โ–บA, โ–บA-empty-01, โ–บA-empty-02, โ–บA-empty-03 -โ”‚ โ””โ”€โ”€ โ–บ:3[4]:main <> origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|1111) โ–บnew-A, โ–บnew-B -โ”œโ”€โ”€ โ–บ:2[0]:origin/main โ†’:3: -โ”‚ โ””โ”€โ”€ โ†’:3: (main โ†’:2:) -โ””โ”€โ”€ โ–บ:5[0]:origin/B โ†’:4: - โ””โ”€โ”€ โ†’:6: - -"#]] - ); - - // Doing this is very much like edit mode, and there is always a segment starting at the entrypoint. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:B <> origin/Bโ‡ก2 on fafd9d0 - โ”œโ”€โ”€ :B <> origin/Bโ‡ก2 - โ”‚ โ”œโ”€โ”€ ยท70e9a36 (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) โ–บtags/without-ref - โ””โ”€โ”€ ๐Ÿ‘‰:anon: - โ”œโ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) โ–บB-empty, โ–บambiguous-01 - โ””โ”€โ”€ โ„70bde6b (๐Ÿ˜๏ธ) โ–บA, โ–บA-empty-01, โ–บA-empty-02, โ–บA-empty-03 - -"#]] - ); - - // If we pass an entrypoint ref name, it will be used as segment name (despite being ambiguous without it) - let graph = Graph::from_commit_traversal( - b_id_1, - tag_ref_name, - &*meta, - project_meta(&*meta), - standard_options(), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท20de6ee (โŒ‚|๐Ÿ˜) -โ”‚ โ””โ”€โ”€ โ–บ:4[1]:B <> origin/B โ†’:5: -โ”‚ โ”œโ”€โ”€ ยท70e9a36 (โŒ‚|๐Ÿ˜|0100) -โ”‚ โ””โ”€โ”€ ยท320e105 (โŒ‚|๐Ÿ˜|0100) โ–บtags/without-ref -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[2]:B-empty -โ”‚ โ””โ”€โ”€ ยท2a31450 (โŒ‚|๐Ÿ˜|0101) โ–บambiguous-01 -โ”‚ โ””โ”€โ”€ โ–บ:6[3]:anon: -โ”‚ โ””โ”€โ”€ ยท70bde6b (โŒ‚|๐Ÿ˜|1101) โ–บA, โ–บA-empty-01, โ–บA-empty-02, โ–บA-empty-03 -โ”‚ โ””โ”€โ”€ โ–บ:3[4]:main <> origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|1111) โ–บnew-A, โ–บnew-B -โ”œโ”€โ”€ โ–บ:2[0]:origin/main โ†’:3: -โ”‚ โ””โ”€โ”€ โ†’:3: (main โ†’:2:) -โ””โ”€โ”€ โ–บ:5[0]:origin/B โ†’:4: - โ””โ”€โ”€ โ†’:6: - -"#]] - ); - - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:B <> origin/Bโ‡ก2 on fafd9d0 - โ”œโ”€โ”€ :B <> origin/Bโ‡ก2 - โ”‚ โ”œโ”€โ”€ ยท70e9a36 (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) โ–บtags/without-ref - โ””โ”€โ”€ ๐Ÿ‘‰:B-empty - โ”œโ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) โ–บambiguous-01 - โ””โ”€โ”€ โ„70bde6b (๐Ÿ˜๏ธ) โ–บA, โ–บA-empty-01, โ–บA-empty-02, โ–บA-empty-03 - -"#]] - ); - Ok(()) -} - -#[test] -fn single_stack_ws_insertions() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/single-stack-ambiguous")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 20de6ee (HEAD -> gitbutler/workspace) GitButler Workspace Commit -* 70e9a36 (B) with-ref -* 320e105 (tag: without-ref) segment-B -* 2a31450 (ambiguous-01, B-empty) segment-B~1 -* 70bde6b (origin/B, A-empty-03, A-empty-02, A-empty-01, A) segment-A -* fafd9d0 (origin/main, new-B, new-A, main) init - -"#]] - ); - // Fully defined workspace with multiple empty segments on top of each other. - // Notably the order doesn't match, 'B-empty' is after 'B', but we use it anyway for segment definition. - // On single commits, the desired order fully defines where stacks go. - // Note that this does match the single-stack (one big segment) configuration we actually have. - add_stack_with_segments( - &mut meta, - 0, - "B-empty", - StackState::InWorkspace, - &[ - "B", - "A-empty-03", - /* A-empty-02 purposefully missing */ "not-A-empty-02", - "A-empty-01", - "A", - ], - ); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท20de6ee (โŒ‚|๐Ÿ˜|0001) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:4[1]:B <> origin/B โ†’:6: -โ”‚ โ”œโ”€โ”€ ยท70e9a36 (โŒ‚|๐Ÿ˜|0101) -โ”‚ โ””โ”€โ”€ ยท320e105 (โŒ‚|๐Ÿ˜|0101) โ–บtags/without-ref -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:3[2]:B-empty -โ”‚ โ””โ”€โ”€ ยท2a31450 (โŒ‚|๐Ÿ˜|0101) โ–บambiguous-01 -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:7[3]:A-empty-03 -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:8[4]:A-empty-01 -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:9[5]:A -โ”‚ โ””โ”€โ”€ ยท70bde6b (โŒ‚|๐Ÿ˜|1101) โ–บA-empty-02 -โ”‚ โ””โ”€โ”€ โ–บ:2[6]:main <> origin/main โ†’:1: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|1111) โ–บnew-A, โ–บnew-B -โ”œโ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ โ†’:2: (main โ†’:1:) -โ””โ”€โ”€ โ–บ:6[0]:origin/B โ†’:4: - โ””โ”€โ”€ โ†’:7: (A-empty-03) - -"#]] - ); - - // We pickup empty segments. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B <> origin/Bโ‡ก2 on fafd9d0 {0} - โ”œโ”€โ”€ ๐Ÿ“™:B <> origin/Bโ‡ก2 - โ”‚ โ”œโ”€โ”€ ยท70e9a36 (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) โ–บtags/without-ref - โ”œโ”€โ”€ ๐Ÿ“™:B-empty - โ”‚ โ””โ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) โ–บambiguous-01 - โ”œโ”€โ”€ ๐Ÿ“™:A-empty-03 - โ”œโ”€โ”€ ๐Ÿ“™:A-empty-01 - โ””โ”€โ”€ ๐Ÿ“™:A - โ””โ”€โ”€ โ„70bde6b (๐Ÿ˜๏ธ) โ–บA-empty-02 - -"#]] - ); - - // Now something similar but with two stacks. - // As the actual topology is different, we can't really comply with that's desired. - // Instead, we reuse as many of the named segments as possible, even if they are from multiple branches. - meta.data_mut().branches.clear(); - add_stack_with_segments(&mut meta, 0, "B-empty", StackState::InWorkspace, &["B"]); - add_stack_with_segments( - &mut meta, - 1, - "A-empty-03", - StackState::InWorkspace, - &["A-empty-02", "A-empty-01", "A"], - ); - - let graph = Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท20de6ee (โŒ‚|๐Ÿ˜|0001) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:4[1]:B <> origin/B โ†’:6: -โ”‚ โ”œโ”€โ”€ ยท70e9a36 (โŒ‚|๐Ÿ˜|0101) -โ”‚ โ””โ”€โ”€ ยท320e105 (โŒ‚|๐Ÿ˜|0101) โ–บtags/without-ref -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:3[2]:B-empty -โ”‚ โ””โ”€โ”€ ยท2a31450 (โŒ‚|๐Ÿ˜|0101) โ–บambiguous-01 -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:7[3]:A-empty-03 -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:8[4]:A-empty-02 -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:9[5]:A-empty-01 -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:10[6]:A -โ”‚ โ””โ”€โ”€ ยท70bde6b (โŒ‚|๐Ÿ˜|1101) -โ”‚ โ””โ”€โ”€ โ–บ:2[7]:main <> origin/main โ†’:1: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|1111) โ–บnew-A, โ–บnew-B -โ”œโ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ โ†’:2: (main โ†’:1:) -โ””โ”€โ”€ โ–บ:6[0]:origin/B โ†’:4: - โ””โ”€โ”€ โ†’:7: (A-empty-03) - -"#]] - ); - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B <> origin/Bโ‡ก2 on fafd9d0 {0} - โ”œโ”€โ”€ ๐Ÿ“™:B <> origin/Bโ‡ก2 - โ”‚ โ”œโ”€โ”€ ยท70e9a36 (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) โ–บtags/without-ref - โ”œโ”€โ”€ ๐Ÿ“™:B-empty - โ”‚ โ””โ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) โ–บambiguous-01 - โ””โ”€โ”€ ๐Ÿ“™:A - โ””โ”€โ”€ โ„70bde6b (๐Ÿ˜๏ธ) - -"#]] - ); - - // Define only some of the branches, it should figure that out. - // It respects the order of the mention in the stack, `A` before `A-empty-01`. - meta.data_mut().branches.clear(); - add_stack_with_segments(&mut meta, 0, "A", StackState::InWorkspace, &["A-empty-01"]); - add_stack_with_segments(&mut meta, 1, "B-empty", StackState::InWorkspace, &["B"]); - - let (id, ref_name) = id_at(&repo, "A-empty-01"); - let graph = Graph::from_commit_traversal( - id, - ref_name, - &*meta, - project_meta(&*meta), - standard_options(), - )?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท20de6ee (โŒ‚|๐Ÿ˜) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:5[1]:B <> origin/B โ†’:6: -โ”‚ โ”œโ”€โ”€ ยท70e9a36 (โŒ‚|๐Ÿ˜|100) -โ”‚ โ””โ”€โ”€ ยท320e105 (โŒ‚|๐Ÿ˜|100) โ–บtags/without-ref -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:4[2]:B-empty -โ”‚ โ””โ”€โ”€ ยท2a31450 (โŒ‚|๐Ÿ˜|100) โ–บambiguous-01 -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:7[3]:A -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™โ–บ:8[4]:A-empty-01 -โ”‚ โ””โ”€โ”€ ยท70bde6b (โŒ‚|๐Ÿ˜|101) โ–บA-empty-02, โ–บA-empty-03 -โ”‚ โ””โ”€โ”€ โ–บ:3[5]:main <> origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|111) โ–บnew-A, โ–บnew-B -โ”œโ”€โ”€ โ–บ:2[0]:origin/main โ†’:3: -โ”‚ โ””โ”€โ”€ โ†’:3: (main โ†’:2:) -โ””โ”€โ”€ โ–บ:6[0]:origin/B โ†’:5: - โ””โ”€โ”€ โ†’:7: (A) - -"#]] - ); - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B <> origin/Bโ‡ก2 on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:B <> origin/Bโ‡ก2 - โ”‚ โ”œโ”€โ”€ ยท70e9a36 (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) โ–บtags/without-ref - โ”œโ”€โ”€ ๐Ÿ“™:B-empty - โ”‚ โ””โ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) โ–บambiguous-01 - โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:A-empty-01 - โ””โ”€โ”€ โ„70bde6b (๐Ÿ˜๏ธ) โ–บA-empty-02, โ–บA-empty-03 - -"#]] - ); - - add_stack_with_segments(&mut meta, 2, "new-A", StackState::InWorkspace, &[]); - add_stack_with_segments(&mut meta, 3, "new-B", StackState::InWorkspace, &[]); - - let (id, ref_name) = id_at(&repo, "new-A"); - let graph = Graph::from_commit_traversal( - id, - ref_name, - &*meta, - project_meta(&*meta), - standard_options(), - )?; - - // We can also summon new empty stacks from branches resting on the base, and set them - // as entrypoint, to have two more stacks. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B <> origin/Bโ‡ก2 on fafd9d0 {1} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B <> origin/Bโ‡ก2 -โ”‚ โ”‚ โ”œโ”€โ”€ ยท70e9a36 (๐Ÿ˜๏ธ) -โ”‚ โ”‚ โ””โ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) โ–บtags/without-ref -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B-empty -โ”‚ โ”‚ โ””โ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) โ–บambiguous-01 -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A-empty-01 -โ”‚ โ””โ”€โ”€ โ„70bde6b (๐Ÿ˜๏ธ) โ–บA-empty-02, โ–บA-empty-03 -โ”œโ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:new-A on fafd9d0 {2} -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:new-A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:new-B on fafd9d0 {3} - โ””โ”€โ”€ ๐Ÿ“™:new-B - -"#]] - ); - Ok(()) -} - -#[test] -fn single_stack() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/single-stack")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 2c12d75 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -* 320e105 (B) segment-B -* 2a31450 (B-sub) segment-B~1 -* 70bde6b (A) segment-A -* fafd9d0 (origin/main, new-A, main) init - -"#]] - ); - - // Just a workspace, no additional ref information. - // It segments across the unambiguous ref names. - add_workspace(&mut meta); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท2c12d75 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:3[1]:B -โ”‚ โ””โ”€โ”€ ยท320e105 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:4[2]:B-sub -โ”‚ โ””โ”€โ”€ ยท2a31450 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:5[3]:A -โ”‚ โ””โ”€โ”€ ยท70bde6b (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:2[4]:main <> origin/main โ†’:1: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|11) โ–บnew-A -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ โ†’:2: (main โ†’:1:) - -"#]] - ); - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:B on fafd9d0 - โ”œโ”€โ”€ :B - โ”‚ โ””โ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ :B-sub - โ”‚ โ””โ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :A - โ””โ”€โ”€ ยท70bde6b (๐Ÿ˜๏ธ) - -"#]] - ); - - meta.data_mut().branches.clear(); - // Just repeat the existing segment verbatim, but also add a new unborn stack - add_stack_with_segments(&mut meta, 0, "B", StackState::InWorkspace, &["B-sub", "A"]); - add_stack_with_segments( - &mut meta, - 1, - "new-A", - StackState::InWorkspace, - &["below-new-A"], - ); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท2c12d75 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:3[1]:B -โ”‚ โ”‚ โ””โ”€โ”€ ยท320e105 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:4[2]:B-sub -โ”‚ โ”‚ โ””โ”€โ”€ ยท2a31450 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:5[3]:A -โ”‚ โ”‚ โ””โ”€โ”€ ยท70bde6b (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:2[4]:main <> origin/main โ†’:1: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:6[1]:new-A -โ”‚ โ””โ”€โ”€ โ†’:2: (main โ†’:1:) -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ โ†’:2: (main โ†’:1:) - -"#]] - ); - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on fafd9d0 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B -โ”‚ โ”‚ โ””โ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B-sub -โ”‚ โ”‚ โ””โ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท70bde6b (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:new-A on fafd9d0 {1} - โ””โ”€โ”€ ๐Ÿ“™:new-A - -"#]] - ); - - Ok(()) -} - -#[test] -fn single_merge_into_main_base_archived() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/single-merge-into-main")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 866c905 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -* c6d714c (C) C -* 0cc5a6f (origin/main, merge, main) Merge branch 'A' into merge -|\ -| * e255adc (A) A -* | 7fdb58d (B) B -|/ -* fafd9d0 init - -"#]] - .raw() - ); - - let stack_id = add_stack_with_segments(&mut meta, 0, "C", StackState::InWorkspace, &["merge"]); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - - // By default, everything with metadata on the branch will show up, even if on the base. - let ws = graph.into_workspace()?; - snapbox::assert_data_eq!( - graph_workspace(&ws).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 0cc5a6f -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 0cc5a6f {0} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ”‚ โ””โ”€โ”€ ยทc6d714c (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:merge - -"#]] - ); - - // But even if everything is marked as archived, only the ones that matter are hidden. - for head in &mut meta - .data_mut() - .branches - .get_mut(&stack_id) - .expect("just added") - .heads - { - head.archived = true; - } - - let graph = ws - .graph - .redo_traversal_with_overlay(&repo, &*meta, Default::default())?; - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 0cc5a6f -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C {0} - โ””โ”€โ”€ ๐Ÿ“™:C - โ””โ”€โ”€ ยทc6d714c (๐Ÿ˜๏ธ) - -"#]] - ); - - // Finally, when the 'merge' branch is independent, it still works as it should. - add_stack_with_segments(&mut meta, 0, "C", StackState::InWorkspace, &[]); - add_stack_with_segments(&mut meta, 1, "merge", StackState::InWorkspace, &[]); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 0cc5a6f -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 0cc5a6f {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:C -โ”‚ โ””โ”€โ”€ ยทc6d714c (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:merge on 0cc5a6f {1} - โ””โ”€โ”€ ๐Ÿ“™:merge - -"#]] - ); - - // The order is respected. - add_stack_with_segments(&mut meta, 1, "C", StackState::InWorkspace, &[]); - add_stack_with_segments(&mut meta, 0, "merge", StackState::InWorkspace, &[]); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 0cc5a6f -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:merge on 0cc5a6f {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:merge -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 0cc5a6f {1} - โ””โ”€โ”€ ๐Ÿ“™:C - โ””โ”€โ”€ ยทc6d714c (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -#[test] -fn minimal_merge_no_refs() -> anyhow::Result<()> { - let (repo, meta) = read_only_in_memory_scenario("ws/dual-merge-no-refs")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 47e1cf1 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -* f40fb16 Merge branch 'C' into merge-2 -|\ -| * c6d714c C -* | 450c58a D -|/ -* 0cc5a6f Merge branch 'A' into merge -|\ -| * e255adc A -* | 7fdb58d B -|/ -* fafd9d0 init - -"#]] - .raw() - ); - - // Without hints. - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] - โ””โ”€โ”€ ยท47e1cf1 (โŒ‚|1) - โ””โ”€โ”€ โ–บ:1[1]:anon: - โ””โ”€โ”€ ยทf40fb16 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:2[2]:anon: - โ”‚ โ””โ”€โ”€ ยท450c58a (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[3]:anon: - โ”‚ โ””โ”€โ”€ ยท0cc5a6f (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ โ–บ:5[4]:anon: - โ”‚ โ”‚ โ””โ”€โ”€ ยท7fdb58d (โŒ‚|1) - โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:7[5]:anon: - โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:6[4]:anon: - โ”‚ โ””โ”€โ”€ ยทe255adc (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:7: - โ””โ”€โ”€ โ–บ:3[2]:anon: - โ””โ”€โ”€ ยทc6d714c (โŒ‚|1) - โ””โ”€โ”€ โ†’:4: - -"#]] - ); - - // This a very untypical setup, but it's not forbidden. Code might want to check - // if the workspace commit is actually managed before proceeding. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -โŒ‚:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:gitbutler/workspace[๐ŸŒณ] {1} - โ””โ”€โ”€ :gitbutler/workspace[๐ŸŒณ] - โ”œโ”€โ”€ ยท47e1cf1 - โ”œโ”€โ”€ ยทf40fb16 - โ”œโ”€โ”€ ยท450c58a - โ”œโ”€โ”€ ยท0cc5a6f - โ”œโ”€โ”€ ยท7fdb58d - โ””โ”€โ”€ ยทfafd9d0 - -"#]] - ); - Ok(()) -} - -#[test] -fn segment_on_each_incoming_connection() -> anyhow::Result<()> { - // Validate that the graph is truly having segments whenever there is an incoming connection. - // This is required to not need special edge-weights. - let (repo, mut meta) = read_only_in_memory_scenario("ws/graph-splitting")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 98c5aba (entrypoint) C -* 807b6ce B -* 6d05486 A -| * b6917c7 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -| * f7fe830 (main) other-2 -|/ -* b688f2d other-1 -* fafd9d0 init - -"#]] - ); - - // Without hints - needs to split `refs/heads/main` at `b688f2d` - let (id, name) = id_at(&repo, "entrypoint"); - add_workspace(&mut meta); - let graph = - Graph::from_commit_traversal(id, name, &*meta, project_meta(&*meta), standard_options())? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:entrypoint -โ”‚ โ”œโ”€โ”€ ยท98c5aba (โŒ‚|1) -โ”‚ โ”œโ”€โ”€ ยท807b6ce (โŒ‚|1) -โ”‚ โ””โ”€โ”€ ยท6d05486 (โŒ‚|1) -โ”‚ โ””โ”€โ”€ โ–บ:3[2]:anon: -โ”‚ โ”œโ”€โ”€ ยทb688f2d (โŒ‚|๐Ÿ˜|1) -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|1) -โ””โ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace[๐ŸŒณ] - โ””โ”€โ”€ ยทb6917c7 (โŒ‚|๐Ÿ˜) - โ””โ”€โ”€ โ–บ:2[1]:main - โ””โ”€โ”€ ยทf7fe830 (โŒ‚|๐Ÿ˜) - โ””โ”€โ”€ โ†’:3: - -"#]] - ); - // This is an unmanaged workspace, even though commits from a workspace flow into it. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -โŒ‚:entrypoint <> โœ“! -โ””โ”€โ”€ โ‰ก:entrypoint {1} - โ””โ”€โ”€ :entrypoint - โ”œโ”€โ”€ ยท98c5aba - โ”œโ”€โ”€ ยท807b6ce - โ”œโ”€โ”€ ยท6d05486 - โ”œโ”€โ”€ ยทb688f2d (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ยทfafd9d0 (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -#[test] -fn minimal_merge() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/dual-merge")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 47e1cf1 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -* f40fb16 (merge-2) Merge branch 'C' into merge-2 -|\ -| * c6d714c (C) C -* | 450c58a (D) D -|/ -* 0cc5a6f (merge, empty-2-on-merge, empty-1-on-merge) Merge branch 'A' into merge -|\ -| * e255adc (A) A -* | 7fdb58d (B) B -|/ -* fafd9d0 (origin/main, main) init - -"#]] - .raw() - ); - - // Without hints, and no workspace data, the branch is normal! - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท47e1cf1 (โŒ‚|01) -โ”‚ โ””โ”€โ”€ โ–บ:1[1]:merge-2 -โ”‚ โ””โ”€โ”€ ยทf40fb16 (โŒ‚|01) -โ”‚ โ”œโ”€โ”€ โ–บ:2[2]:D -โ”‚ โ”‚ โ””โ”€โ”€ ยท450c58a (โŒ‚|01) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:4[3]:anon: -โ”‚ โ”‚ โ””โ”€โ”€ ยท0cc5a6f (โŒ‚|01) โ–บempty-1-on-merge, โ–บempty-2-on-merge, โ–บmerge -โ”‚ โ”‚ โ”œโ”€โ”€ โ–บ:5[4]:B -โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ ยท7fdb58d (โŒ‚|01) -โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:7[5]:main <> origin/main โ†’:8: -โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|11) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:6[4]:A -โ”‚ โ”‚ โ””โ”€โ”€ ยทe255adc (โŒ‚|01) -โ”‚ โ”‚ โ””โ”€โ”€ โ†’:7: (main โ†’:8:) -โ”‚ โ””โ”€โ”€ โ–บ:3[2]:C -โ”‚ โ””โ”€โ”€ ยทc6d714c (โŒ‚|01) -โ”‚ โ””โ”€โ”€ โ†’:4: -โ””โ”€โ”€ โ–บ:8[0]:origin/main โ†’:7: - โ””โ”€โ”€ โ†’:7: (main โ†’:8:) - -"#]] - ); - - // Without workspace data this becomes a single-branch workspace, with `main` as normal segment. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -โŒ‚:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:gitbutler/workspace[๐ŸŒณ] {1} - โ”œโ”€โ”€ :gitbutler/workspace[๐ŸŒณ] - โ”‚ โ””โ”€โ”€ ยท47e1cf1 - โ”œโ”€โ”€ :merge-2 - โ”‚ โ””โ”€โ”€ ยทf40fb16 - โ”œโ”€โ”€ :D - โ”‚ โ”œโ”€โ”€ ยท450c58a - โ”‚ โ””โ”€โ”€ ยท0cc5a6f โ–บempty-1-on-merge, โ–บempty-2-on-merge, โ–บmerge - โ”œโ”€โ”€ :B - โ”‚ โ””โ”€โ”€ ยท7fdb58d - โ””โ”€โ”€ :main <> origin/main - โ””โ”€โ”€ โ„๏ธfafd9d0 - -"#]] - ); - - // There is empty stacks on top of `merge`, and they need to be connected to the incoming segments and the outgoing ones. - // This also would leave the original segment empty unless we managed to just put empty stacks on top. - add_stack_with_segments( - &mut meta, - 0, - "empty-2-on-merge", - StackState::InWorkspace, - &["empty-1-on-merge", "merge"], - ); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท47e1cf1 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:6[1]:merge-2 -โ”‚ โ””โ”€โ”€ ยทf40fb16 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”œโ”€โ”€ โ–บ:7[2]:D -โ”‚ โ”‚ โ””โ”€โ”€ ยท450c58a (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:9[3]:empty-2-on-merge -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:10[4]:empty-1-on-merge -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:11[5]:merge -โ”‚ โ”‚ โ””โ”€โ”€ ยท0cc5a6f (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ”œโ”€โ”€ โ–บ:4[6]:B -โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ ยท7fdb58d (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:2[7]:main <> origin/main โ†’:1: -โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:5[6]:A -โ”‚ โ”‚ โ””โ”€โ”€ ยทe255adc (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ””โ”€โ”€ โ†’:2: (main โ†’:1:) -โ”‚ โ””โ”€โ”€ โ–บ:8[2]:C -โ”‚ โ””โ”€โ”€ ยทc6d714c (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ†’:9: (empty-2-on-merge) -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ โ†’:2: (main โ†’:1:) - -"#]] - ); - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:merge-2 on fafd9d0 {0} - โ”œโ”€โ”€ :merge-2 - โ”‚ โ””โ”€โ”€ ยทf40fb16 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ :D - โ”‚ โ””โ”€โ”€ ยท450c58a (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ๐Ÿ“™:empty-2-on-merge - โ”œโ”€โ”€ ๐Ÿ“™:empty-1-on-merge - โ”œโ”€โ”€ ๐Ÿ“™:merge - โ”‚ โ””โ”€โ”€ ยท0cc5a6f (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :B - โ””โ”€โ”€ ยท7fdb58d (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -#[test] -fn entrypoint_inside_second_parent_of_workspace_diamond_is_included() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/dual-merge")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 47e1cf1 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -* f40fb16 (merge-2) Merge branch 'C' into merge-2 -|\ -| * c6d714c (C) C -* | 450c58a (D) D -|/ -* 0cc5a6f (merge, empty-2-on-merge, empty-1-on-merge) Merge branch 'A' into merge -|\ -| * e255adc (A) A -* | 7fdb58d (B) B -|/ -* fafd9d0 (origin/main, main) init - -"#]] - .raw() - ); - add_workspace(&mut meta); - let (id, name) = id_at(&repo, "C"); - let graph = - Graph::from_commit_traversal(id, name, &*meta, project_meta(&*meta), standard_options())? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท47e1cf1 (โŒ‚|๐Ÿ˜) -โ”‚ โ””โ”€โ”€ โ–บ:5[1]:merge-2 -โ”‚ โ””โ”€โ”€ ยทf40fb16 (โŒ‚|๐Ÿ˜) -โ”‚ โ”œโ”€โ”€ โ–บ:8[2]:D -โ”‚ โ”‚ โ””โ”€โ”€ ยท450c58a (โŒ‚|๐Ÿ˜) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:4[3]:anon: -โ”‚ โ”‚ โ””โ”€โ”€ ยท0cc5a6f (โŒ‚|๐Ÿ˜|01) โ–บempty-1-on-merge, โ–บempty-2-on-merge, โ–บmerge -โ”‚ โ”‚ โ”œโ”€โ”€ โ–บ:6[4]:B -โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ ยท7fdb58d (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:3[5]:main <> origin/main โ†’:2: -โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:7[4]:A -โ”‚ โ”‚ โ””โ”€โ”€ ยทe255adc (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ””โ”€โ”€ โ†’:3: (main โ†’:2:) -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[2]:C -โ”‚ โ””โ”€โ”€ ยทc6d714c (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ†’:4: -โ””โ”€โ”€ โ–บ:2[0]:origin/main โ†’:3: - โ””โ”€โ”€ โ†’:3: (main โ†’:2:) - -"#]] - ); - - let ws = graph.into_workspace()?; - let entrypoint_stack_segment = ws - .stacks - .iter() - .flat_map(|stack| stack.segments.iter()) - .find(|segment| segment.is_entrypoint) - .expect("entrypoint segment must stay in a workspace stack"); - assert!( - entrypoint_stack_segment - .commits - .iter() - .any(|commit| commit.id == id.detach()), - "the entrypoint stack segment must contain the custom traversal commit" - ); - snapbox::assert_data_eq!( - graph_workspace(&ws).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:merge-2 on fafd9d0 - โ”œโ”€โ”€ :merge-2 - โ”‚ โ””โ”€โ”€ ยทf40fb16 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ๐Ÿ‘‰:C - โ”‚ โ”œโ”€โ”€ ยทc6d714c (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ ยท0cc5a6f (๐Ÿ˜๏ธ) โ–บempty-1-on-merge, โ–บempty-2-on-merge, โ–บmerge - โ””โ”€โ”€ :B - โ””โ”€โ”€ ยท7fdb58d (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -#[test] -fn stack_configuration_is_respected_if_one_of_them_is_an_entrypoint() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/just-init-with-two-branches")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* fafd9d0 (HEAD -> gitbutler/workspace, main, B, A) init - -"#]] - ); - - add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &[]); - add_stack_with_segments(&mut meta, 2, "B", StackState::InWorkspace, &[]); - - let extra_target_options = standard_options_with_extra_target(&repo, "main"); - let graph = Graph::from_head( - &repo, - &*meta, - project_meta(&*meta), - extra_target_options.clone(), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] - โ”œโ”€โ”€ ๐Ÿ“™โ–บ:2[1]:A - โ”‚ โ””โ”€โ”€ โ–บ:1[2]:anon: - โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|1) โ–บmain - โ””โ”€โ”€ ๐Ÿ“™โ–บ:3[1]:B - โ””โ”€โ”€ โ†’:1: - -"#]] - ); - assert_eq!( - graph.entrypoint()?.commit().map(|c| c.id), - extra_target_options.extra_target_commit_id, - "entrypoint points to a virtual workspace tip segment \ - which can't unambiguously find the commit" - ); - assert!( - graph - .tip_skip_empty(graph.entrypoint()?.segment.id) - .is_none(), - "no unique path leads to a commit when starting at the segment" - ); - let ws = graph.into_workspace()?; - assert_eq!( - ws.tip_commit_by_segment_id(ws.id).map(|commit| commit.id), - extra_target_options.extra_target_commit_id, - "workspace query falls back to the ref-info commit for ambiguous empty segments" - ); - snapbox::assert_data_eq!( - graph_workspace(&ws).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on fafd9d0 {2} - โ””โ”€โ”€ ๐Ÿ“™:B - -"#]] - ); - - let (id, ref_name) = id_at(&repo, "B"); - let graph = Graph::from_commit_traversal( - id, - ref_name.clone(), - &*meta, - project_meta(&*meta), - extra_target_options.clone(), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace[๐ŸŒณ] - โ”œโ”€โ”€ ๐Ÿ“™โ–บ:2[1]:A - โ”‚ โ””โ”€โ”€ โ–บ:0[2]:anon: - โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|1) โ–บmain - โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™โ–บ:3[1]:B - โ””โ”€โ”€ โ†’:0: - -"#]] - ); - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:B on fafd9d0 {2} - โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:B - -"#]] - ); - - let (id, ref_name) = id_at(&repo, "A"); - let graph = Graph::from_commit_traversal( - id, - ref_name.clone(), - &*meta, - project_meta(&*meta), - extra_target_options, - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace[๐ŸŒณ] - โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“™โ–บ:2[1]:A - โ”‚ โ””โ”€โ”€ โ–บ:0[2]:anon: - โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|1) โ–บmain - โ””โ”€โ”€ ๐Ÿ“™โ–บ:3[1]:B - โ””โ”€โ”€ โ†’:0: - -"#]] - ); - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:A on fafd9d0 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on fafd9d0 {2} - โ””โ”€โ”€ ๐Ÿ“™:B - -"#]] - ); - - Ok(()) -} - -#[test] -fn just_init_with_branches() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/just-init-with-branches")?; - // Note the dedicated workspace branch without a workspace commit. - // All is fair game, and we use it to validate 'empty parent branch handling after new children took the commit'. - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* fafd9d0 (HEAD -> main, origin/main, gitbutler/workspace, F, E, D, C, B, A) init - -"#]] - ); - - // Without hints - `main` is picked up as it's the entrypoint. - add_workspace(&mut meta); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[1]:main[๐ŸŒณ] <> origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|1) โ–บA, โ–บB, โ–บC, โ–บD, โ–บE, โ–บF -โ””โ”€โ”€ โ–บ:2[0]:origin/main โ†’:0: - โ””โ”€โ”€ โ†’:0: (main[๐ŸŒณ] โ†’:2:) - -"#]] - ); - - // There is no workspace as `main` is the base of the workspace, so it's shown directly - // as a downgraded single-branch view. The target context is preserved, and the fully - // integrated base commit is pruned while keeping the branch container. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -โŒ‚:main[๐ŸŒณ] <> โœ“refs/remotes/origin/main -โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] <> origin/main {1} - โ””โ”€โ”€ :main[๐ŸŒณ] <> origin/main - -"#]] - ); - - let (id, ws_ref_name) = id_at(&repo, "gitbutler/workspace"); - let graph = Graph::from_commit_traversal( - id, - ws_ref_name.clone(), - &*meta, - but_core::ref_metadata::ProjectMeta::default(), - standard_options(), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace -โ”‚ โ””โ”€โ”€ โ–บ:1[1]:main[๐ŸŒณ] <> origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|1) โ–บA, โ–บB, โ–บC, โ–บD, โ–บE, โ–บF -โ””โ”€โ”€ โ–บ:2[0]:origin/main โ†’:1: - โ””โ”€โ”€ โ†’:1: (main[๐ŸŒณ] โ†’:2:) - -"#]] - ); - - // However, when the workspace is checked out, it's at least empty. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“! -โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] <> origin/main - โ””โ”€โ”€ :main[๐ŸŒณ] <> origin/main - โ””โ”€โ”€ โ„๏ธfafd9d0 (๐Ÿ˜๏ธ) โ–บA, โ–บB, โ–บC, โ–บD, โ–บE, โ–บF - -"#]] - ); - - // The simplest possible setup where we can define how the workspace should look like, - // in terms of dependent and independent virtual segments. - add_stack_with_segments(&mut meta, 0, "C", StackState::InWorkspace, &["B", "A"]); - add_stack_with_segments(&mut meta, 1, "D", StackState::InWorkspace, &["E", "F"]); - - let graph = Graph::from_head( - &repo, - &*meta, - but_core::ref_metadata::ProjectMeta::default(), - standard_options(), - )?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:3[1]:C -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:4[2]:B -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:5[3]:A -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[4]:main[๐ŸŒณ] <> origin/main โ†’:2: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|1) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:6[1]:D -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:7[2]:E -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:8[3]:F -โ”‚ โ””โ”€โ”€ โ†’:0: (main[๐ŸŒณ] โ†’:2:) -โ””โ”€โ”€ โ–บ:2[0]:origin/main โ†’:0: - โ””โ”€โ”€ โ†’:0: (main[๐ŸŒณ] โ†’:2:) - -"#]] - ); - - // With empty project metadata, workspace segmentation is retained around the workspace ref. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“! on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on fafd9d0 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:D on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:D - โ”œโ”€โ”€ ๐Ÿ“™:E - โ””โ”€โ”€ ๐Ÿ“™:F - -"#]] - ); - - let graph = Graph::from_commit_traversal( - id, - ws_ref_name.clone(), - &*meta, - project_meta(&*meta), - standard_options(), - )? - .validated()?; - // Now the dependent segments are applied, and so is the separate stack. - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:3[1]:C -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:4[2]:B -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:5[3]:A -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:2[4]:main[๐ŸŒณ] <> origin/main โ†’:1: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:6[1]:D -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:7[2]:E -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:8[3]:F -โ”‚ โ””โ”€โ”€ โ†’:2: (main[๐ŸŒณ] โ†’:1:) -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ โ†’:2: (main[๐ŸŒณ] โ†’:1:) - -"#]] - ); - - let mut ws = graph.into_workspace()?; - snapbox::assert_data_eq!( - graph_workspace(&ws).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on fafd9d0 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:D on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:D - โ”œโ”€โ”€ ๐Ÿ“™:E - โ””โ”€โ”€ ๐Ÿ“™:F - -"#]] - ); - - ws.graph.anonymize(&repo.remote_names())?; - snapbox::assert_data_eq!( - graph_workspace(&ws.graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:A <> โœ“! on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:C -โ””โ”€โ”€ โ‰ก๐Ÿ“™:D on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:D - โ”œโ”€โ”€ ๐Ÿ“™:E - โ””โ”€โ”€ ๐Ÿ“™:F - -"#]] - ); - - let graph = Graph::from_commit_traversal( - id, - ws_ref_name, - &*meta, - project_meta(&*meta), - but_graph::init::Options { - dangerously_skip_postprocessing_for_debugging: true, - ..standard_options() - }, - )? - .validated()?; - // Show how the lack of post-processing affects the graph - remotes are also not connected. - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace -โ”‚ โ””โ”€โ”€ โ–บ:2[0]:anon: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|1) โ–บA, โ–บB, โ–บC, โ–บD, โ–บE, โ–บF, โ–บmain[๐ŸŒณ], โ–บorigin/main -โ””โ”€โ”€ โ–บ:1[0]:origin/main - โ””โ”€โ”€ โ†’:2: - -"#]] - ); - - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 - -"#]] - ); - - Ok(()) -} - -#[test] -fn tips_equivalent_to_workspace_metadata_are_order_independent() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/just-init-with-branches")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* fafd9d0 (HEAD -> main, origin/main, gitbutler/workspace, F, E, D, C, B, A) init - -"#]] - ); - - add_workspace(&mut meta); - add_stack_with_segments(&mut meta, 0, "C", StackState::InWorkspace, &["B", "A"]); - add_stack_with_segments(&mut meta, 1, "D", StackState::InWorkspace, &["E", "F"]); - - let (id, ws_ref_name) = id_at(&repo, "gitbutler/workspace"); - let commit_id = id.detach(); - let workspace_metadata = (*meta.workspace(ws_ref_name.as_ref())?).clone(); - let main_ref = super::ref_name("refs/heads/main"); - let origin_main_ref = super::ref_name("refs/remotes/origin/main"); - let stack_ref = |name: &str| super::ref_name(&format!("refs/heads/{name}")); - - let head_baseline = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - let head_baseline_tree = graph_tree(&head_baseline).to_string(); - let head_baseline_workspace = graph_workspace(&head_baseline.into_workspace()?).to_string(); - - let head_tips = vec![ - Tip::new(commit_id).with_role(TipRole::WorkspaceStackBranch { - desired_ref_name: stack_ref("F"), - }), - Tip::new(commit_id) - .with_ref_name(Some(ws_ref_name.clone())) - .with_role(TipRole::Workspace) - .with_metadata(SegmentMetadata::Workspace(workspace_metadata.clone())), - Tip::new(commit_id).with_role(TipRole::WorkspaceStackBranch { - desired_ref_name: stack_ref("B"), - }), - Tip::new(commit_id) - .with_ref_name(Some(origin_main_ref.clone())) - .with_role(TipRole::TargetRemote), - Tip::new(commit_id).with_role(TipRole::WorkspaceStackBranch { - desired_ref_name: stack_ref("A"), - }), - Tip::new(commit_id) - .with_ref_name(Some(main_ref.clone())) - .with_entrypoint(), - Tip::new(commit_id).with_role(TipRole::WorkspaceStackBranch { - desired_ref_name: stack_ref("E"), - }), - Tip::new(commit_id).with_role(TipRole::WorkspaceStackBranch { - desired_ref_name: stack_ref("C"), - }), - Tip::new(commit_id).with_role(TipRole::WorkspaceStackBranch { - desired_ref_name: stack_ref("D"), - }), - ]; - - let workspace_baseline = Graph::from_commit_traversal( - id, - ws_ref_name.clone(), - &*meta, - project_meta(&*meta), - standard_options(), - )? - .validated()?; - let workspace_baseline_tree = graph_tree(&workspace_baseline).to_string(); - let workspace_baseline_workspace = graph_workspace(&workspace_baseline.into_workspace()?); - snapbox::assert_data_eq!( - workspace_baseline_workspace.to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on fafd9d0 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:D on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:D - โ”œโ”€โ”€ ๐Ÿ“™:E - โ””โ”€โ”€ ๐Ÿ“™:F - -"#]] - ); - let workspace_baseline_workspace = workspace_baseline_workspace.to_string(); - - let explicit_tips = vec![ - Tip::new(commit_id).with_role(TipRole::WorkspaceStackBranch { - desired_ref_name: stack_ref("E"), - }), - Tip::new(commit_id).with_role(TipRole::TargetLocal { - local_ref_name: main_ref.clone(), - }), - Tip::new(commit_id) - .with_ref_name(Some(ws_ref_name.clone())) - .with_role(TipRole::Workspace) - .with_metadata(SegmentMetadata::Workspace(workspace_metadata)) - .with_entrypoint(), - Tip::new(commit_id).with_role(TipRole::WorkspaceStackBranch { - desired_ref_name: stack_ref("C"), - }), - Tip::new(commit_id) - .with_ref_name(Some(origin_main_ref)) - .with_role(TipRole::TargetRemote), - Tip::new(commit_id).with_role(TipRole::WorkspaceStackBranch { - desired_ref_name: stack_ref("F"), - }), - Tip::new(commit_id).with_role(TipRole::WorkspaceStackBranch { - desired_ref_name: stack_ref("A"), - }), - Tip::new(commit_id).with_role(TipRole::WorkspaceStackBranch { - desired_ref_name: stack_ref("D"), - }), - Tip::new(commit_id).with_role(TipRole::WorkspaceStackBranch { - desired_ref_name: stack_ref("B"), - }), - ]; - let graph = Graph::from_commit_traversal_tips( - &repo, - head_tips, - &*meta, - project_meta(&*meta), - standard_options(), - )? - .validated()?; - assert_eq!( - graph_tree(&graph).to_string(), - head_baseline_tree, - "unordered explicit tips with a reachable entrypoint should match HEAD traversal" - ); - assert_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - head_baseline_workspace, - "unordered explicit tips with a reachable entrypoint should match the HEAD workspace projection" - ); - - let graph = Graph::from_commit_traversal_tips( - &repo, - explicit_tips.clone(), - &*meta, - project_meta(&*meta), - standard_options(), - )? - .validated()?; - assert_eq!( - graph_tree(&graph).to_string(), - workspace_baseline_tree, - "unordered explicit tips should create the same graph as workspace metadata traversal" - ); - let explicit_workspace = graph_workspace(&graph.into_workspace()?); - snapbox::assert_data_eq!( - explicit_workspace.to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on fafd9d0 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:D on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:D - โ”œโ”€โ”€ ๐Ÿ“™:E - โ””โ”€โ”€ ๐Ÿ“™:F - -"#]] - ); - assert_eq!( - explicit_workspace.to_string(), - workspace_baseline_workspace, - "unordered explicit tips should create the same workspace projection as workspace metadata traversal" - ); - - Ok(()) -} - -#[test] -fn workspace_target_commit_and_extra_target_commit_can_overlap() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/just-init-with-two-branches")?; - let target_id = id_by_rev(&repo, "main").detach(); - add_workspace_with_target(&mut meta, target_id); - add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &[]); - add_stack_with_segments(&mut meta, 2, "B", StackState::InWorkspace, &[]); - - let baseline = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - let baseline_tree = graph_tree(&baseline).to_string(); - let baseline_workspace = graph_workspace(&baseline.into_workspace()?).to_string(); - - let graph = Graph::from_head( - &repo, - &*meta, - project_meta(&*meta), - standard_options().with_extra_target_commit_id(target_id), - )? - .validated()?; - - assert_eq!( - graph_tree(&graph).to_string(), - baseline_tree, - "duplicated synthetic integrated tips should not change graph traversal" - ); - assert_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - baseline_workspace, - "duplicated synthetic integrated tips should not change workspace projection" - ); - - Ok(()) -} - -#[test] -fn duplicate_workspace_stack_branch_tips_from_metadata_are_ignored() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/just-init-with-two-branches")?; - add_workspace(&mut meta); - add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &[]); - add_stack_with_segments(&mut meta, 2, "B", StackState::InWorkspace, &[]); - - let baseline = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - let baseline_tree = graph_tree(&baseline).to_string(); - let baseline_workspace = graph_workspace(&baseline.into_workspace()?).to_string(); - - add_stack_with_segments(&mut meta, 3, "B", StackState::InWorkspace, &[]); - let graph = Graph::from_head( - &repo, - &*meta, - but_core::ref_metadata::ProjectMeta::default(), - standard_options(), - )? - .validated()?; - - assert_eq!( - graph_tree(&graph).to_string(), - baseline_tree, - "duplicate stack branch metadata (B) should not enqueue the same stack branch traversal twice" - ); - assert_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - baseline_workspace, - "duplicate stack branch metadata should not change workspace projection" - ); - - Ok(()) -} - -#[test] -fn just_init_with_archived_branches() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/just-init-with-branches")?; - // Note the dedicated workspace branch without a workspace commit. - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* fafd9d0 (HEAD -> main, origin/main, gitbutler/workspace, F, E, D, C, B, A) init - -"#]] - ); - - let stack_id = add_stack_with_segments(&mut meta, 0, "C", StackState::InWorkspace, &["B", "A"]); - - let (id, ws_ref_name) = id_at(&repo, "gitbutler/workspace"); - let graph = Graph::from_commit_traversal( - id, - ws_ref_name.clone(), - &*meta, - project_meta(&*meta), - standard_options(), - )? - .validated()?; - - // By default, we see both stacks as they are configured, which disambiguates them. - let ws = graph.into_workspace()?; - snapbox::assert_data_eq!( - graph_workspace(&ws).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on fafd9d0 {0} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ”œโ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ๐Ÿ“™:A - -"#]] - ); - - meta.data_mut() - .branches - .get_mut(&stack_id) - .expect("just added") - .heads[1] - .archived = true; - - // The first archived segment causes everything else to be hidden. - let graph = ws - .graph - .redo_traversal_with_overlay(&repo, &*meta, Default::default())?; - let ws = graph.into_workspace()?; - snapbox::assert_data_eq!( - graph_workspace(&ws).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C {0} - โ””โ”€โ”€ ๐Ÿ“™:C - -"#]] - ); - - let heads = &mut meta.data_mut().branches.get_mut(&stack_id).unwrap().heads; - heads[0].archived = true; - heads[1].archived = false; - - // Now only the first one is archived. - let graph = ws - .graph - .redo_traversal_with_overlay(&repo, &*meta, Default::default())?; - let ws = graph.into_workspace()?; - snapbox::assert_data_eq!( - graph_workspace(&ws).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C {0} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ””โ”€โ”€ ๐Ÿ“™:B - -"#]] - ); - - let heads = &mut meta.data_mut().branches.get_mut(&stack_id).unwrap().heads; - heads[0].archived = true; - heads[1].archived = true; - heads[2].archived = true; - - // Archiving everything removes the stack entirely. - let graph = ws - .graph - .redo_traversal_with_overlay(&repo, &*meta, Default::default())?; - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 - -"#]] - ); - Ok(()) -} - -#[test] -fn two_stacks_many_refs() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/one-stacks-many-refs")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 298d938 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -* 16f132b (S1, G, F) 2 -* 917b9da (E, D) 1 -* fafd9d0 (origin/main, main, C, B, A) init - -"#]] - ); - - add_workspace(&mut meta); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - // Without any information it looks quite barren. - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท298d938 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:3[1]:anon: -โ”‚ โ”œโ”€โ”€ ยท16f132b (โŒ‚|๐Ÿ˜|01) โ–บF, โ–บG, โ–บS1 -โ”‚ โ””โ”€โ”€ ยท917b9da (โŒ‚|๐Ÿ˜|01) โ–บD, โ–บE -โ”‚ โ””โ”€โ”€ โ–บ:2[2]:main <> origin/main โ†’:1: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|11) โ–บA, โ–บB, โ–บC -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ โ†’:2: (main โ†’:1:) - -"#]] - ); - - // With no workspace at all as the workspace segment isn't split. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:anon: on fafd9d0 - โ””โ”€โ”€ :anon: - โ”œโ”€โ”€ ยท16f132b (๐Ÿ˜๏ธ) โ–บF, โ–บG, โ–บS1 - โ””โ”€โ”€ ยท917b9da (๐Ÿ˜๏ธ) โ–บD, โ–บE - -"#]] - ); - - let (id, ref_name) = id_at(&repo, "S1"); - let graph = Graph::from_commit_traversal( - id, - ref_name.clone(), - &*meta, - project_meta(&*meta), - standard_options(), - )? - .validated()?; - // The S1 starting position is a split, so there is more. - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท298d938 (โŒ‚|๐Ÿ˜) -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[1]:S1 -โ”‚ โ”œโ”€โ”€ ยท16f132b (โŒ‚|๐Ÿ˜|01) โ–บF, โ–บG -โ”‚ โ””โ”€โ”€ ยท917b9da (โŒ‚|๐Ÿ˜|01) โ–บD, โ–บE -โ”‚ โ””โ”€โ”€ โ–บ:3[2]:main <> origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|11) โ–บA, โ–บB, โ–บC -โ””โ”€โ”€ โ–บ:2[0]:origin/main โ†’:3: - โ””โ”€โ”€ โ†’:3: (main โ†’:2:) - -"#]] - ); - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:S1 on fafd9d0 - โ””โ”€โ”€ ๐Ÿ‘‰:S1 - โ”œโ”€โ”€ ยท16f132b (๐Ÿ˜๏ธ) โ–บF, โ–บG - โ””โ”€โ”€ ยท917b9da (๐Ÿ˜๏ธ) โ–บD, โ–บE - -"#]] - ); - - // Define the workspace. - add_stack_with_segments(&mut meta, 1, "C", StackState::InWorkspace, &["B"]); - add_stack_with_segments(&mut meta, 2, "A", StackState::InWorkspace, &[]); - add_stack_with_segments(&mut meta, 3, "S1", StackState::InWorkspace, &["G", "F"]); - add_stack_with_segments(&mut meta, 4, "D", StackState::InWorkspace, &["E"]); - - // We see that all segments are used: S1 C B A E D G F - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท298d938 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:5[1]:C -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:6[2]:B -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:2[6]:main <> origin/main โ†’:1: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:7[1]:A -โ”‚ โ”‚ โ””โ”€โ”€ โ†’:2: (main โ†’:1:) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:8[1]:S1 -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:9[2]:G -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:10[3]:F -โ”‚ โ””โ”€โ”€ ยท16f132b (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:11[4]:D -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:12[5]:E -โ”‚ โ””โ”€โ”€ ยท917b9da (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ†’:2: (main โ†’:1:) -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ โ†’:2: (main โ†’:1:) - -"#]] - ); - - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on fafd9d0 {1} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {2} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:S1 on fafd9d0 {3} - โ”œโ”€โ”€ ๐Ÿ“™:S1 - โ”œโ”€โ”€ ๐Ÿ“™:G - โ”œโ”€โ”€ ๐Ÿ“™:F - โ”‚ โ””โ”€โ”€ ยท16f132b (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:E - โ””โ”€โ”€ ยท917b9da (๐Ÿ˜๏ธ) - -"#]] - ); - - let graph = Graph::from_commit_traversal( - id, - ref_name.clone(), - &*meta, - project_meta(&*meta), - standard_options(), - )? - .validated()?; - // This should look the same as before, despite the starting position. - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท298d938 (โŒ‚|๐Ÿ˜) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:5[1]:C -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:6[2]:B -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:3[6]:main <> origin/main โ†’:2: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:7[1]:A -โ”‚ โ”‚ โ””โ”€โ”€ โ†’:3: (main โ†’:2:) -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™โ–บ:8[1]:S1 -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:9[2]:G -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:10[3]:F -โ”‚ โ””โ”€โ”€ ยท16f132b (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:11[4]:D -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:12[5]:E -โ”‚ โ””โ”€โ”€ ยท917b9da (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ†’:3: (main โ†’:2:) -โ””โ”€โ”€ โ–บ:2[0]:origin/main โ†’:3: - โ””โ”€โ”€ โ†’:3: (main โ†’:2:) - -"#]] - ); - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on fafd9d0 {1} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {2} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:S1 on fafd9d0 {3} - โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:S1 - โ”œโ”€โ”€ ๐Ÿ“™:G - โ”œโ”€โ”€ ๐Ÿ“™:F - โ”‚ โ””โ”€โ”€ ยท16f132b (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:E - โ””โ”€โ”€ ยท917b9da (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -#[test] -fn just_init_with_branches_complex() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/just-init-with-branches")?; - - // A combination of dependent and independent stacks. - add_stack_with_segments(&mut meta, 0, "C", StackState::InWorkspace, &["B"]); - add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &[]); - add_stack_with_segments(&mut meta, 2, "D", StackState::InWorkspace, &["E"]); - add_stack_with_segments(&mut meta, 3, "F", StackState::InWorkspace, &[]); - - let (id, ref_name) = id_at(&repo, "gitbutler/workspace"); - let graph = Graph::from_commit_traversal( - id, - ref_name.clone(), - &*meta, - project_meta(&*meta), - standard_options(), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:3[1]:C -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:4[2]:B -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:2[3]:main[๐ŸŒณ] <> origin/main โ†’:1: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:5[1]:A -โ”‚ โ”‚ โ””โ”€โ”€ โ†’:2: (main[๐ŸŒณ] โ†’:1:) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:6[1]:D -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:7[2]:E -โ”‚ โ”‚ โ””โ”€โ”€ โ†’:2: (main[๐ŸŒณ] โ†’:1:) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:8[1]:F -โ”‚ โ””โ”€โ”€ โ†’:2: (main[๐ŸŒณ] โ†’:1:) -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ โ†’:2: (main[๐ŸŒณ] โ†’:1:) - -"#]] - ); - - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on fafd9d0 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:D on fafd9d0 {2} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:D -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:E -โ””โ”€โ”€ โ‰ก๐Ÿ“™:F on fafd9d0 {3} - โ””โ”€โ”€ ๐Ÿ“™:F - -"#]] - ); - - let (id, ref_name) = id_at(&repo, "C"); - let graph = Graph::from_commit_traversal( - id, - ref_name.clone(), - &*meta, - project_meta(&*meta), - standard_options(), - )? - .validated()?; - // The entrypoint shouldn't affect the outcome (even though it changes the initial segmentation). - // However, as the segment it's on is integrated, it's not considered to be part of the workspace. - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace -โ”‚ โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“™โ–บ:3[1]:C -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:4[2]:B -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:0[3]:main[๐ŸŒณ] <> origin/main โ†’:2: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:5[1]:A -โ”‚ โ”‚ โ””โ”€โ”€ โ†’:0: (main[๐ŸŒณ] โ†’:2:) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:6[1]:D -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:7[2]:E -โ”‚ โ”‚ โ””โ”€โ”€ โ†’:0: (main[๐ŸŒณ] โ†’:2:) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:8[1]:F -โ”‚ โ””โ”€โ”€ โ†’:0: (main[๐ŸŒณ] โ†’:2:) -โ””โ”€โ”€ โ–บ:2[0]:origin/main โ†’:0: - โ””โ”€โ”€ โ†’:0: (main[๐ŸŒณ] โ†’:2:) - -"#]] - ); - - // We should see the same stacks as we did before, just with a different entrypoint. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“refs/remotes/origin/main on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:C on fafd9d0 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:C -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:D on fafd9d0 {2} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:D -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:E -โ””โ”€โ”€ โ‰ก๐Ÿ“™:F on fafd9d0 {3} - โ””โ”€โ”€ ๐Ÿ“™:F - -"#]] - ); - Ok(()) -} - -#[test] -fn proper_remote_ahead() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/proper-remote-ahead")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 9bcd3af (HEAD -> gitbutler/workspace) GitButler Workspace Commit -| * ca7baa7 (origin/main) only-remote-02 -| * 7ea1468 only-remote-01 -|/ -* 998eae6 (main) shared -* fafd9d0 init - -"#]] - ); - - // Remote segments are picked up automatically and traversed - they never take ownership of already assigned commits. - add_workspace(&mut meta); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท9bcd3af (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:2[1]:main <> origin/main โ†’:1: -โ”‚ โ”œโ”€โ”€ ยท998eae6 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|11) -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ”œโ”€โ”€ ๐ŸŸฃca7baa7 (โœ“) - โ””โ”€โ”€ ๐ŸŸฃ7ea1468 (โœ“) - โ””โ”€โ”€ โ†’:2: (main โ†’:1:) - -"#]] - ); - - // Everything in the workspace is integrated, thus it's empty. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ2 on 998eae6 - -"#]] - ); - - let (id, ref_name) = id_at(&repo, "main"); - // The integration branch can be in the workspace and be checked out. - let graph = Graph::from_commit_traversal( - id, - Some(ref_name), - &*meta, - project_meta(&*meta), - standard_options(), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท9bcd3af (โŒ‚|๐Ÿ˜) -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[1]:main <> origin/main โ†’:2: -โ”‚ โ”œโ”€โ”€ ยท998eae6 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|1) -โ””โ”€โ”€ โ–บ:2[0]:origin/main โ†’:0: - โ”œโ”€โ”€ ๐ŸŸฃca7baa7 (โœ“) - โ””โ”€โ”€ ๐ŸŸฃ7ea1468 (โœ“) - โ””โ”€โ”€ โ†’:0: (main โ†’:2:) - -"#]] - ); - - // If it's checked out, we must show the branch container, but it's not part of the - // managed workspace. The target context is preserved and integrated local/base commits - // are pruned, leaving only target-side commits ahead of the stored target. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -โŒ‚:main <> โœ“refs/remotes/origin/mainโ‡ฃ2 -โ””โ”€โ”€ โ‰ก:main <> origin/mainโ‡ฃ2 {1} - โ””โ”€โ”€ :main <> origin/mainโ‡ฃ2 - โ”œโ”€โ”€ ๐ŸŸฃca7baa7 (โœ“) - โ””โ”€โ”€ ๐ŸŸฃ7ea1468 (โœ“) - -"#]] - ); - Ok(()) -} - -#[test] -fn deduced_remote_ahead() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/deduced-remote-ahead")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 8b39ce4 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -* 9d34471 (A) A2 -* 5b89c71 A1 -| * 3ea1a8f (push-remote/A, origin/A) only-remote-02 -| * 9c50f71 only-remote-01 -| * 2cfbb79 merge -|/| -| * e898cd0 feat-on-remote -|/ -* 998eae6 shared -* fafd9d0 (main) init - -"#]] - ); - - // Remote segments are picked up automatically and traversed - they never take ownership of already assigned commits. - add_workspace(&mut meta); - let graph = Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท8b39ce4 (โŒ‚|๐Ÿ˜|001) -โ”‚ โ””โ”€โ”€ โ–บ:1[1]:A <> origin/A โ†’:2: -โ”‚ โ”œโ”€โ”€ ยท9d34471 (โŒ‚|๐Ÿ˜|011) -โ”‚ โ””โ”€โ”€ ยท5b89c71 (โŒ‚|๐Ÿ˜|011) -โ”‚ โ””โ”€โ”€ โ–บ:5[3]:anon: -โ”‚ โ””โ”€โ”€ ยท998eae6 (โŒ‚|๐Ÿ˜|111) -โ”‚ โ””โ”€โ”€ โ–บ:3[4]:main -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|111) -โ””โ”€โ”€ โ–บ:2[0]:origin/A โ†’:1: - โ”œโ”€โ”€ ๐ŸŸฃ3ea1a8f (0x0|100) - โ””โ”€โ”€ ๐ŸŸฃ9c50f71 (0x0|100) - โ””โ”€โ”€ โ–บ:4[1]:anon: - โ””โ”€โ”€ ๐ŸŸฃ2cfbb79 (0x0|100) - โ”œโ”€โ”€ โ†’:5: - โ””โ”€โ”€ โ–บ:6[2]:anon: - โ””โ”€โ”€ ๐ŸŸฃe898cd0 (0x0|100) - โ””โ”€โ”€ โ†’:5: - -"#]] - ); - // There is no target branch, so nothing is integrated, and `main` shows up. - // It's not special. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:A <> origin/Aโ‡ก2โ‡ฃ4 - โ”œโ”€โ”€ :A <> origin/Aโ‡ก2โ‡ฃ4 - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ3ea1a8f - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ9c50f71 - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ2cfbb79 - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃe898cd0 - โ”‚ โ”œโ”€โ”€ ยท9d34471 (๐Ÿ˜๏ธ) - โ”‚ โ”œโ”€โ”€ ยท5b89c71 (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ โ„๏ธ998eae6 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :main - โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) - -"#]] - ); - - let id = id_by_rev(&repo, ":/init"); - let graph = - Graph::from_commit_traversal(id, None, &*meta, project_meta(&*meta), standard_options())?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท8b39ce4 (โŒ‚|๐Ÿ˜) -โ”‚ โ””โ”€โ”€ โ–บ:2[1]:A <> origin/A โ†’:3: -โ”‚ โ”œโ”€โ”€ ยท9d34471 (โŒ‚|๐Ÿ˜|010) -โ”‚ โ””โ”€โ”€ ยท5b89c71 (โŒ‚|๐Ÿ˜|010) -โ”‚ โ””โ”€โ”€ โ–บ:5[3]:anon: -โ”‚ โ””โ”€โ”€ ยท998eae6 (โŒ‚|๐Ÿ˜|110) -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[4]:main -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|111) -โ””โ”€โ”€ โ–บ:3[0]:origin/A โ†’:2: - โ”œโ”€โ”€ ๐ŸŸฃ3ea1a8f (0x0|100) - โ””โ”€โ”€ ๐ŸŸฃ9c50f71 (0x0|100) - โ””โ”€โ”€ โ–บ:4[1]:anon: - โ””โ”€โ”€ ๐ŸŸฃ2cfbb79 (0x0|100) - โ”œโ”€โ”€ โ†’:5: - โ””โ”€โ”€ โ–บ:6[2]:anon: - โ””โ”€โ”€ ๐ŸŸฃe898cd0 (0x0|100) - โ””โ”€โ”€ โ†’:5: - -"#]] - ); - // The whole workspace is visible, but it's clear where the entrypoint is. - // As there is no target ref, `main` shows up. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:A <> origin/Aโ‡ก2โ‡ฃ4 - โ”œโ”€โ”€ :A <> origin/Aโ‡ก2โ‡ฃ4 - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ3ea1a8f - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ9c50f71 - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ2cfbb79 - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃe898cd0 - โ”‚ โ”œโ”€โ”€ ยท9d34471 (๐Ÿ˜๏ธ) - โ”‚ โ”œโ”€โ”€ ยท5b89c71 (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ โ„๏ธ998eae6 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ‘‰:main - โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) - -"#]] - ); - - // When the push-remote is configured, it overrides the remote we use for listing, even if a fetch remote is available. - let mut ws = meta.workspace(WORKSPACE_REF_NAME.try_into().expect("valid workspace ref"))?; - let mut pm = ws.project_meta(); - pm.push_remote = Some("push-remote".into()); - ws.set_project_meta(pm); - meta.set_workspace(&ws)?; - let graph = Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท8b39ce4 (โŒ‚|๐Ÿ˜|001) -โ”‚ โ””โ”€โ”€ โ–บ:1[1]:A <> push-remote/A โ†’:2: -โ”‚ โ”œโ”€โ”€ ยท9d34471 (โŒ‚|๐Ÿ˜|011) -โ”‚ โ””โ”€โ”€ ยท5b89c71 (โŒ‚|๐Ÿ˜|011) -โ”‚ โ””โ”€โ”€ โ–บ:5[3]:anon: -โ”‚ โ””โ”€โ”€ ยท998eae6 (โŒ‚|๐Ÿ˜|111) -โ”‚ โ””โ”€โ”€ โ–บ:3[4]:main -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|111) -โ””โ”€โ”€ โ–บ:2[0]:push-remote/A โ†’:1: - โ”œโ”€โ”€ ๐ŸŸฃ3ea1a8f (0x0|100) - โ””โ”€โ”€ ๐ŸŸฃ9c50f71 (0x0|100) - โ””โ”€โ”€ โ–บ:4[1]:anon: - โ””โ”€โ”€ ๐ŸŸฃ2cfbb79 (0x0|100) - โ”œโ”€โ”€ โ†’:5: - โ””โ”€โ”€ โ–บ:6[2]:anon: - โ””โ”€โ”€ ๐ŸŸฃe898cd0 (0x0|100) - โ””โ”€โ”€ โ†’:5: - -"#]] - ); - - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:A <> push-remote/Aโ‡ก2โ‡ฃ4 - โ”œโ”€โ”€ :A <> push-remote/Aโ‡ก2โ‡ฃ4 - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ3ea1a8f - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ9c50f71 - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ2cfbb79 - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃe898cd0 - โ”‚ โ”œโ”€โ”€ ยท9d34471 (๐Ÿ˜๏ธ) - โ”‚ โ”œโ”€โ”€ ยท5b89c71 (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ โ„๏ธ998eae6 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :main - โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -#[test] -fn stacked_rebased_remotes() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/remote-includes-another-remote")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 682be32 (origin/B) B -* e29c23d (origin/A) A -| * 7786959 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -| * 312f819 (B) B -| * e255adc (A) A -|/ -* fafd9d0 (origin/main, main) init - -"#]] - ); - - // This is like remotes have been stacked and are completely rebased so they differ from their local - // commits. This also means they include each other. - add_workspace(&mut meta); - let graph = Graph::from_head( - &repo, - &*meta, - but_core::ref_metadata::ProjectMeta::default(), - standard_options(), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท7786959 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:1[1]:B -โ”‚ โ””โ”€โ”€ ยท312f819 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:2[2]:A -โ”‚ โ””โ”€โ”€ ยทe255adc (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:3[3]:main <> origin/main โ†’:4: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|11) -โ””โ”€โ”€ โ–บ:4[0]:origin/main โ†’:3: - โ””โ”€โ”€ โ†’:3: (main โ†’:4:) - -"#]] - ); - // It's worth noting that we avoid double-listing remote commits that are also - // directly owned by another remote segment. - // they have to be considered as something relevant to the branch history. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:B - โ”œโ”€โ”€ :B - โ”‚ โ””โ”€โ”€ ยท312f819 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ :A - โ”‚ โ””โ”€โ”€ ยทe255adc (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :main <> origin/main - โ””โ”€โ”€ โ„๏ธfafd9d0 (๐Ÿ˜๏ธ) - -"#]] - ); - - // The result is the same when changing the entrypoint. - let (id, name) = id_at(&repo, "A"); - let graph = - Graph::from_commit_traversal(id, name, &*meta, project_meta(&*meta), standard_options())? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท7786959 (โŒ‚|๐Ÿ˜) -โ”‚ โ””โ”€โ”€ โ–บ:5[1]:B <> origin/B โ†’:6: -โ”‚ โ””โ”€โ”€ ยท312f819 (โŒ‚|๐Ÿ˜|01000) -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[2]:A <> origin/A โ†’:4: -โ”‚ โ””โ”€โ”€ ยทe255adc (โŒ‚|๐Ÿ˜|01001) -โ”‚ โ””โ”€โ”€ โ–บ:3[3]:main <> origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|11111) -โ”œโ”€โ”€ โ–บ:2[0]:origin/main โ†’:3: -โ”‚ โ””โ”€โ”€ โ†’:3: (main โ†’:2:) -โ””โ”€โ”€ โ–บ:6[0]:origin/B โ†’:5: - โ””โ”€โ”€ ๐ŸŸฃ682be32 (0x0|10000) - โ””โ”€โ”€ โ–บ:4[1]:origin/A โ†’:0: - โ””โ”€โ”€ ๐ŸŸฃe29c23d (0x0|10100) - โ””โ”€โ”€ โ†’:3: (main โ†’:2:) - -"#]] - ); - let ws = graph.into_workspace()?; - snapbox::assert_data_eq!( - graph_workspace(&ws).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:B <> origin/Bโ‡ก1โ‡ฃ1 on fafd9d0 - โ”œโ”€โ”€ :B <> origin/Bโ‡ก1โ‡ฃ1 - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ682be32 - โ”‚ โ””โ”€โ”€ ยท312f819 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ‘‰:A <> origin/Aโ‡ก1โ‡ฃ1 - โ”œโ”€โ”€ ๐ŸŸฃe29c23d - โ””โ”€โ”€ ยทe255adc (๐Ÿ˜๏ธ) - -"#]] - ); - snapbox::assert_data_eq!( - ws.graph.statistics().to_debug(), - snapbox::str![[r#" -Statistics { - segments: 7, - segments_integrated: 1, - segments_remote: 2, - segments_with_remote_tracking_branch: 3, - segments_empty: 1, - 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, - 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/main", - ), - ), - NodeIndex(2), - None, - ), - ( - Some( - FullName( - "refs/remotes/origin/B", - ), - ), - NodeIndex(6), - Some( - CommitFlags( - 0x100, - ), - ), - ), - ], - segments_at_bottom: 1, - connections: 6, - commits: 6, - commit_references: 0, - commits_at_cutoff: 0, -} - -"#]] - ); - Ok(()) -} - -#[test] -fn target_with_remote_on_stack_tip() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/local-target-ahead-and-on-stack-tip")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* dd0cca8 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -* e255adc (main, A) A -* fafd9d0 (origin/main) init - -"#]] - ); - add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &[]); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยทdd0cca8 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:2[1]:A -โ”‚ โ””โ”€โ”€ ยทe255adc (โŒ‚|๐Ÿ˜|11) -โ”‚ โ””โ”€โ”€ โ–บ:1[2]:origin/main โ†’:3: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|11) -โ””โ”€โ”€ โ–บ:3[0]:main <> origin/main โ†’:1: - โ””โ”€โ”€ โ†’:2: (A) - -"#]] - ); - - // The main branch is not present, as it's the target. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} - โ””โ”€โ”€ ๐Ÿ“™:A - โ””โ”€โ”€ ยทe255adc (๐Ÿ˜๏ธ) - -"#]] - ); - - // But mention it if it's in the workspace. It should retain order. - add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &["main"]); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:A - โ””โ”€โ”€ ๐Ÿ“™:main <> origin/mainโ‡ก1 - โ””โ”€โ”€ ยทe255adc (๐Ÿ˜๏ธ) - -"#]] - ); - - // But mention it if it's in the workspace. It should retain order - inverting the order is fine. - add_stack_with_segments(&mut meta, 1, "main", StackState::InWorkspace, &["A"]); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:main <> origin/main on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:main <> origin/main - โ””โ”€โ”€ ๐Ÿ“™:A - โ””โ”€โ”€ ยทe255adc (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -#[test] -fn disambiguate_by_remote() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/disambiguate-by-remote")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* e30f90c (HEAD -> gitbutler/workspace) GitButler Workspace Commit -* 2173153 (origin/ambiguous-C, origin/C, ambiguous-C, C) C -| * ac24e74 (origin/B) remote-of-B -|/ -* 312f819 (ambiguous-B, B) B -* e255adc (origin/A, ambiguous-A, A) A -* fafd9d0 (origin/main, main) init - -"#]] - ); - - add_workspace(&mut meta); - // As remote connections point at segments, if these stream back into their local tracking - // branch, and the segment is unnamed, and the first commit is ambiguous name-wise, we - // use the remote tracking branch to disambiguate the segment. After all, it's beneficial - // to have properly wired segments. - // Note that this is more complicated if the local tracking branch is also advanced, but - // this is something to improve when workspace-less operation becomes a thing *and* we - // need to get better as disambiguation. - // The target branch is actually counted as remote, but it doesn't come through here as - // it steals the commit from `main`. This should be fine. - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยทe30f90c (โŒ‚|๐Ÿ˜|000001) -โ”‚ โ””โ”€โ”€ โ–บ:6[1]:anon: -โ”‚ โ””โ”€โ”€ ยท2173153 (โŒ‚|๐Ÿ˜|000101) โ–บC, โ–บambiguous-C -โ”‚ โ””โ”€โ”€ โ–บ:9[2]:B <> origin/B โ†’:5: -โ”‚ โ””โ”€โ”€ ยท312f819 (โŒ‚|๐Ÿ˜|011101) โ–บambiguous-B -โ”‚ โ””โ”€โ”€ โ–บ:8[3]:A <> origin/A โ†’:7: -โ”‚ โ””โ”€โ”€ ยทe255adc (โŒ‚|๐Ÿ˜|111101) โ–บambiguous-A -โ”‚ โ””โ”€โ”€ โ–บ:2[4]:main <> origin/main โ†’:1: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|111111) -โ”œโ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ โ†’:2: (main โ†’:1:) -โ”œโ”€โ”€ โ–บ:3[0]:origin/C -โ”‚ โ””โ”€โ”€ โ†’:6: -โ”œโ”€โ”€ โ–บ:4[0]:origin/ambiguous-C -โ”‚ โ””โ”€โ”€ โ†’:6: -โ”œโ”€โ”€ โ–บ:5[0]:origin/B โ†’:9: -โ”‚ โ””โ”€โ”€ ๐ŸŸฃac24e74 (0x0|010000) -โ”‚ โ””โ”€โ”€ โ†’:9: (B โ†’:5:) -โ””โ”€โ”€ โ–บ:7[0]:origin/A โ†’:8: - โ””โ”€โ”€ โ†’:8: (A โ†’:7:) - -"#]] - ); - - assert_eq!( - graph.partial_segments().count(), - 0, - "a fully realized graph" - ); - // An anonymous segment to start with is alright, and can always happen for other situations as well. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:anon: on fafd9d0 - โ”œโ”€โ”€ :anon: - โ”‚ โ””โ”€โ”€ ยท2173153 (๐Ÿ˜๏ธ) โ–บC, โ–บambiguous-C - โ”œโ”€โ”€ :B <> origin/Bโ‡ฃ1 - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃac24e74 - โ”‚ โ””โ”€โ”€ โ„๏ธ312f819 (๐Ÿ˜๏ธ) โ–บambiguous-B - โ””โ”€โ”€ :A <> origin/A - โ””โ”€โ”€ โ„๏ธe255adc (๐Ÿ˜๏ธ) โ–บambiguous-A - -"#]] - ); - - // If 'C' is in the workspace, it's naturally disambiguated. - add_stack_with_segments(&mut meta, 0, "C", StackState::InWorkspace, &[]); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยทe30f90c (โŒ‚|๐Ÿ˜|000001) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:3[1]:C <> origin/C โ†’:4: -โ”‚ โ””โ”€โ”€ ยท2173153 (โŒ‚|๐Ÿ˜|000101) โ–บambiguous-C -โ”‚ โ””โ”€โ”€ โ–บ:9[2]:B <> origin/B โ†’:6: -โ”‚ โ””โ”€โ”€ ยท312f819 (โŒ‚|๐Ÿ˜|011101) โ–บambiguous-B -โ”‚ โ””โ”€โ”€ โ–บ:8[3]:A <> origin/A โ†’:7: -โ”‚ โ””โ”€โ”€ ยทe255adc (โŒ‚|๐Ÿ˜|111101) โ–บambiguous-A -โ”‚ โ””โ”€โ”€ โ–บ:2[4]:main <> origin/main โ†’:1: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|111111) -โ”œโ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ โ†’:2: (main โ†’:1:) -โ”œโ”€โ”€ โ–บ:4[0]:origin/C โ†’:3: -โ”‚ โ””โ”€โ”€ โ†’:3: (C โ†’:4:) -โ”œโ”€โ”€ โ–บ:5[0]:origin/ambiguous-C -โ”‚ โ””โ”€โ”€ โ†’:3: (C โ†’:4:) -โ”œโ”€โ”€ โ–บ:6[0]:origin/B โ†’:9: -โ”‚ โ””โ”€โ”€ ๐ŸŸฃac24e74 (0x0|010000) -โ”‚ โ””โ”€โ”€ โ†’:9: (B โ†’:6:) -โ””โ”€โ”€ โ–บ:7[0]:origin/A โ†’:8: - โ””โ”€โ”€ โ†’:8: (A โ†’:7:) - -"#]] - ); - // And because `C` is in the workspace data, its data is denoted. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C <> origin/C on fafd9d0 {0} - โ”œโ”€โ”€ ๐Ÿ“™:C <> origin/C - โ”‚ โ””โ”€โ”€ โ„๏ธ2173153 (๐Ÿ˜๏ธ) โ–บambiguous-C - โ”œโ”€โ”€ :B <> origin/Bโ‡ฃ1 - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃac24e74 - โ”‚ โ””โ”€โ”€ โ„๏ธ312f819 (๐Ÿ˜๏ธ) โ–บambiguous-B - โ””โ”€โ”€ :A <> origin/A - โ””โ”€โ”€ โ„๏ธe255adc (๐Ÿ˜๏ธ) โ–บambiguous-A - -"#]] - ); - Ok(()) -} - -#[test] -fn integrated_tips_stop_early_if_remote_is_not_configured() -> anyhow::Result<()> { - let (repo, mut meta) = - read_only_in_memory_scenario("ws/two-segments-one-integrated-without-remote")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 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 -| | * 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 - -"#]] - .raw() - ); - - add_workspace(&mut meta); - // We can abort early if there is only integrated commits left, but also if there is *no remote setup*. - // We also abort integrated named segments early, unless these are named as being part of the - // workspace - here `A` is cut off. - // Without remote, the traversal can't setup `main` as target for the workspace entrypoint to find. - let graph = Graph::from_head( - &repo, - &*meta, - but_core::ref_metadata::ProjectMeta::default(), - standard_options(), - )? - .validated()?; - assert_eq!(graph.partial_segments().count(), 0); - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] - โ””โ”€โ”€ ยท4077353 (โŒ‚|๐Ÿ˜|1) - โ””โ”€โ”€ โ–บ:1[1]:B - โ”œโ”€โ”€ ยท6b1a13b (โŒ‚|๐Ÿ˜|1) - โ””โ”€โ”€ ยท03ad472 (โŒ‚|๐Ÿ˜|1) - โ””โ”€โ”€ โ–บ:2[2]:A - โ”œโ”€โ”€ ยท79bbb29 (โŒ‚|๐Ÿ˜|1) - โ”œโ”€โ”€ ยทfc98174 (โŒ‚|๐Ÿ˜|1) - โ”œโ”€โ”€ ยทa381df5 (โŒ‚|๐Ÿ˜|1) - โ””โ”€โ”€ ยท777b552 (โŒ‚|๐Ÿ˜|1) - โ””โ”€โ”€ โ–บ:3[3]:anon: - โ””โ”€โ”€ ยทce4a760 (โŒ‚|๐Ÿ˜|1) - โ”œโ”€โ”€ โ–บ:4[5]:anon: - โ”‚ โ””โ”€โ”€ ยท01d0e1e (โŒ‚|๐Ÿ˜|1) - โ”‚ โ””โ”€โ”€ โ–บ:6[6]:main - โ”‚ โ”œโ”€โ”€ ยท4b3e5a8 (โŒ‚|๐Ÿ˜|1) - โ”‚ โ”œโ”€โ”€ ยท34d0715 (โŒ‚|๐Ÿ˜|1) - โ”‚ โ””โ”€โ”€ ๐Ÿยทeb5f731 (โŒ‚|๐Ÿ˜|1) - โ””โ”€โ”€ โ–บ:5[4]:A-feat - โ”œโ”€โ”€ ยทfea59b5 (โŒ‚|๐Ÿ˜|1) - โ””โ”€โ”€ ยท4deea74 (โŒ‚|๐Ÿ˜|1) - โ””โ”€โ”€ โ†’:4: - -"#]] - ); - // It's true that `A` is fully integrated so it isn't displayed. so from a workspace-perspective - // it's the right answer. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:B - โ”œโ”€โ”€ :B - โ”‚ โ”œโ”€โ”€ ยท6b1a13b (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ ยท03ad472 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ :A - โ”‚ โ”œโ”€โ”€ ยท79bbb29 (๐Ÿ˜๏ธ) - โ”‚ โ”œโ”€โ”€ ยทfc98174 (๐Ÿ˜๏ธ) - โ”‚ โ”œโ”€โ”€ ยทa381df5 (๐Ÿ˜๏ธ) - โ”‚ โ”œโ”€โ”€ ยท777b552 (๐Ÿ˜๏ธ) - โ”‚ โ”œโ”€โ”€ ยทce4a760 (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ ยท01d0e1e (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :main - โ”œโ”€โ”€ ยท4b3e5a8 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ยท34d0715 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ยทeb5f731 (๐Ÿ˜๏ธ) - -"#]] - ); - - add_stack_with_segments(&mut meta, 0, "B", StackState::InWorkspace, &["A"]); - // ~~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. - // And since it's integrated, traversal is stopped without convergence. - // We see more though as we add workspace segments immediately. - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท4077353 (โŒ‚|๐Ÿ˜|1) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:2[1]:B -โ”‚ โ”œโ”€โ”€ ยท6b1a13b (โŒ‚|๐Ÿ˜|1) -โ”‚ โ””โ”€โ”€ ยท03ad472 (โŒ‚|๐Ÿ˜|1) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:3[2]:A -โ”‚ โ”œโ”€โ”€ ยท79bbb29 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”œโ”€โ”€ ยทfc98174 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”œโ”€โ”€ ยทa381df5 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ ยท777b552 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ โ–บ:6[3]:anon: -โ”‚ โ””โ”€โ”€ โœ‚ยทce4a760 (โŒ‚|๐Ÿ˜|โœ“|1) -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:5: - โ”œโ”€โ”€ ๐ŸŸฃd0df794 (โœ“) - โ””โ”€โ”€ ๐ŸŸฃ09c6e08 (โœ“) - โ””โ”€โ”€ โ–บ:4[1]:anon: - โ””โ”€โ”€ ๐ŸŸฃ7b9f260 (โœ“) - โ”œโ”€โ”€ โ–บ:5[2]:main <> origin/main โ†’:1: - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ4b3e5a8 (โœ“) - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ34d0715 (โœ“) - โ”‚ โ””โ”€โ”€ ๐Ÿ๐ŸŸฃeb5f731 (โœ“) - โ””โ”€โ”€ โ†’:3: (A) - -"#]] - ); - // `A` is integrated, hence it's not shown. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ6 on 79bbb29 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 79bbb29 {0} - โ””โ”€โ”€ ๐Ÿ“™:B - โ”œโ”€โ”€ ยท6b1a13b (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ยท03ad472 (๐Ÿ˜๏ธ) - -"#]] - ); - - // The limit is effective for integrated workspaces branches, and it doesn't unnecessarily - // prolong the traversal once the all tips are known to be integrated. - let graph = Graph::from_head( - &repo, - &*meta, - project_meta(&*meta), - standard_options().with_limit_hint(1), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท4077353 (โŒ‚|๐Ÿ˜|1) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:2[1]:B -โ”‚ โ”œโ”€โ”€ ยท6b1a13b (โŒ‚|๐Ÿ˜|1) -โ”‚ โ””โ”€โ”€ ยท03ad472 (โŒ‚|๐Ÿ˜|1) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:3[2]:A -โ”‚ โ”œโ”€โ”€ ยท79bbb29 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”œโ”€โ”€ ยทfc98174 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”œโ”€โ”€ ยทa381df5 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ โœ‚ยท777b552 (โŒ‚|๐Ÿ˜|โœ“|1) -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:5: - โ”œโ”€โ”€ ๐ŸŸฃd0df794 (โœ“) - โ””โ”€โ”€ ๐ŸŸฃ09c6e08 (โœ“) - โ””โ”€โ”€ โ–บ:4[1]:anon: - โ””โ”€โ”€ ๐ŸŸฃ7b9f260 (โœ“) - โ”œโ”€โ”€ โ–บ:5[2]:main <> origin/main โ†’:1: - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ4b3e5a8 (โœ“) - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ34d0715 (โœ“) - โ”‚ โ””โ”€โ”€ ๐Ÿ๐ŸŸฃeb5f731 (โœ“) - โ””โ”€โ”€ โ†’:3: (A) - -"#]] - ); - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ6 on 79bbb29 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 79bbb29 {0} - โ””โ”€โ”€ ๐Ÿ“™:B - โ”œโ”€โ”€ ยท6b1a13b (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ยท03ad472 (๐Ÿ˜๏ธ) - -"#]] - ); - - meta.data_mut().branches.clear(); - add_workspace(&mut meta); - // When looking from an integrated branch within the workspace, but without limit, - // the (lack of) limit is respected. - // When the entrypoint starts on an integrated commit, the 'all-tips-are-integrated' condition doesn't - // kick in anymore. - let (id, ref_name) = id_at(&repo, "A"); - let graph = Graph::from_commit_traversal( - id, - ref_name.clone(), - &*meta, - project_meta(&*meta), - standard_options(), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท4077353 (โŒ‚|๐Ÿ˜) -โ”‚ โ””โ”€โ”€ โ–บ:4[1]:B -โ”‚ โ”œโ”€โ”€ ยท6b1a13b (โŒ‚|๐Ÿ˜) -โ”‚ โ””โ”€โ”€ ยท03ad472 (โŒ‚|๐Ÿ˜) -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[2]:A -โ”‚ โ”œโ”€โ”€ ยท79bbb29 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”œโ”€โ”€ ยทfc98174 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”œโ”€โ”€ ยทa381df5 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ ยท777b552 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ โ–บ:6[3]:anon: -โ”‚ โ””โ”€โ”€ ยทce4a760 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”œโ”€โ”€ โ–บ:7[5]:anon: -โ”‚ โ”‚ โ””โ”€โ”€ ยท01d0e1e (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:5[6]:main <> origin/main โ†’:2: -โ”‚ โ”‚ โ”œโ”€โ”€ ยท4b3e5a8 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ”œโ”€โ”€ ยท34d0715 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทeb5f731 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ โ–บ:8[4]:A-feat -โ”‚ โ”œโ”€โ”€ ยทfea59b5 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ ยท4deea74 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ โ†’:7: -โ””โ”€โ”€ โ–บ:2[0]:origin/main โ†’:5: - โ”œโ”€โ”€ ๐ŸŸฃd0df794 (โœ“) - โ””โ”€โ”€ ๐ŸŸฃ09c6e08 (โœ“) - โ””โ”€โ”€ โ–บ:3[1]:anon: - โ””โ”€โ”€ ๐ŸŸฃ7b9f260 (โœ“) - โ”œโ”€โ”€ โ†’:5: (main โ†’:2:) - โ””โ”€โ”€ โ†’:0: (A) - -"#]] - ); - // The entrypoint branch is downgraded to a single-branch view with target context - // preserved. All commits on this branch are integrated, so the branch container remains - // but its commit list is pruned. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -โŒ‚:A <> โœ“refs/remotes/origin/mainโ‡ฃ3 -โ””โ”€โ”€ โ‰ก:A on 4b3e5a8 {1} - โ””โ”€โ”€ :A - -"#]] - ); - - let graph = Graph::from_commit_traversal( - id, - ref_name, - &*meta, - project_meta(&*meta), - standard_options().with_limit_hint(1), - )? - .validated()?; - // It's still getting quite far despite the limit due to other heads searching for their goals, - // but also ends traversal early. - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท4077353 (โŒ‚|๐Ÿ˜) -โ”‚ โ””โ”€โ”€ โ–บ:4[1]:B -โ”‚ โ”œโ”€โ”€ ยท6b1a13b (โŒ‚|๐Ÿ˜) -โ”‚ โ””โ”€โ”€ ยท03ad472 (โŒ‚|๐Ÿ˜) -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[2]:A -โ”‚ โ”œโ”€โ”€ ยท79bbb29 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”œโ”€โ”€ ยทfc98174 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”œโ”€โ”€ ยทa381df5 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ โœ‚ยท777b552 (โŒ‚|๐Ÿ˜|โœ“|1) -โ””โ”€โ”€ โ–บ:2[0]:origin/main โ†’:5: - โ”œโ”€โ”€ ๐ŸŸฃd0df794 (โœ“) - โ””โ”€โ”€ ๐ŸŸฃ09c6e08 (โœ“) - โ””โ”€โ”€ โ–บ:3[1]:anon: - โ””โ”€โ”€ ๐ŸŸฃ7b9f260 (โœ“) - โ”œโ”€โ”€ โ–บ:5[2]:main <> origin/main โ†’:2: - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ4b3e5a8 (โœ“) - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ34d0715 (โœ“) - โ”‚ โ””โ”€โ”€ ๐Ÿ๐ŸŸฃeb5f731 (โœ“) - โ””โ”€โ”€ โ†’:0: (A) - -"#]] - ); - // Because the branch is integrated, the surrounding workspace isn't shown. The downgraded - // branch view keeps target context and prunes the integrated commits. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -โŒ‚:A <> โœ“refs/remotes/origin/mainโ‡ฃ6 -โ””โ”€โ”€ โ‰ก:A {1} - โ””โ”€โ”€ :A - -"#]] - ); - - // See what happens with an out-of-workspace HEAD and an arbitrary extra target. - let (id, _ref_name) = id_at(&repo, "origin/main"); - let graph = Graph::from_commit_traversal( - id, - None, - &*meta, - project_meta(&*meta), - standard_options_with_extra_target(&repo, "gitbutler/workspace"), - )? - .validated()?; - // It keeps the tip-settings of the workspace it setup by itself, and doesn't override this - // with the extra-target settings. - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท4077353 (โŒ‚|๐Ÿ˜) -โ”‚ โ””โ”€โ”€ โ–บ:4[1]:B -โ”‚ โ”œโ”€โ”€ ยท6b1a13b (โŒ‚|๐Ÿ˜) -โ”‚ โ””โ”€โ”€ ยท03ad472 (โŒ‚|๐Ÿ˜) -โ”‚ โ””โ”€โ”€ โ–บ:6[3]:A -โ”‚ โ”œโ”€โ”€ ยท79bbb29 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”œโ”€โ”€ ยทfc98174 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”œโ”€โ”€ ยทa381df5 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ ยท777b552 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ โ–บ:7[4]:anon: -โ”‚ โ””โ”€โ”€ ยทce4a760 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”œโ”€โ”€ โ–บ:8[6]:anon: -โ”‚ โ”‚ โ””โ”€โ”€ ยท01d0e1e (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:5[7]:main <> origin/main โ†’:2: -โ”‚ โ”‚ โ”œโ”€โ”€ ยท4b3e5a8 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ”œโ”€โ”€ ยท34d0715 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทeb5f731 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ โ–บ:9[5]:A-feat -โ”‚ โ”œโ”€โ”€ ยทfea59b5 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ ยท4deea74 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ โ†’:8: -โ””โ”€โ”€ โ–บ:2[0]:origin/main โ†’:5: - โ””โ”€โ”€ โ–บ:0[1]:anon: - โ”œโ”€โ”€ ๐Ÿ‘‰ยทd0df794 (โŒ‚|โœ“|1) - โ””โ”€โ”€ ยท09c6e08 (โŒ‚|โœ“|1) - โ””โ”€โ”€ โ–บ:3[2]:anon: - โ””โ”€โ”€ ยท7b9f260 (โŒ‚|โœ“|1) - โ”œโ”€โ”€ โ†’:5: (main โ†’:2:) - โ””โ”€โ”€ โ†’:6: (A) - -"#]] - ); - - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -โŒ‚:DETACHED <> โœ“refs/remotes/origin/mainโ‡ฃ3 on 79bbb29 -โ””โ”€โ”€ โ‰ก:anon: on 4b3e5a8 {1} - โ””โ”€โ”€ :anon: - โ”œโ”€โ”€ ยทd0df794 (โœ“) - โ”œโ”€โ”€ ยท09c6e08 (โœ“) - โ””โ”€โ”€ ยท7b9f260 (โœ“) - -"#]] - ); - - // However, when choosing an initially unknown branch, it will get the extra target tip settings. - let graph = Graph::from_commit_traversal( - id, - None, - &*meta, - project_meta(&*meta), - standard_options_with_extra_target(&repo, "B"), - )? - .validated()?; - // For now we don't do anything to limit the each in single-branch mode using extra-targets. - // Thanks to the limit-transplant we get to discover more of the workspace. - // TODO(extra-target): make it work so they limit single branches even, but it's a special case - // as we can't have remotes here. - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท4077353 (โŒ‚|๐Ÿ˜) -โ”‚ โ””โ”€โ”€ โ–บ:3[1]:B -โ”‚ โ”œโ”€โ”€ ยท6b1a13b (โŒ‚|๐Ÿ˜|โœ“) -โ”‚ โ””โ”€โ”€ ยท03ad472 (โŒ‚|๐Ÿ˜|โœ“) -โ”‚ โ””โ”€โ”€ โ–บ:6[3]:A -โ”‚ โ”œโ”€โ”€ ยท79bbb29 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”œโ”€โ”€ ยทfc98174 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”œโ”€โ”€ ยทa381df5 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ ยท777b552 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ โ–บ:7[4]:anon: -โ”‚ โ””โ”€โ”€ ยทce4a760 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”œโ”€โ”€ โ–บ:8[6]:anon: -โ”‚ โ”‚ โ””โ”€โ”€ ยท01d0e1e (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:5[7]:main <> origin/main โ†’:2: -โ”‚ โ”‚ โ”œโ”€โ”€ ยท4b3e5a8 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ”œโ”€โ”€ ยท34d0715 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทeb5f731 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ โ–บ:9[5]:A-feat -โ”‚ โ”œโ”€โ”€ ยทfea59b5 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ ยท4deea74 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ โ†’:8: -โ””โ”€โ”€ โ–บ:2[0]:origin/main โ†’:5: - โ””โ”€โ”€ โ–บ:0[1]:anon: - โ”œโ”€โ”€ ๐Ÿ‘‰ยทd0df794 (โŒ‚|โœ“|1) - โ””โ”€โ”€ ยท09c6e08 (โŒ‚|โœ“|1) - โ””โ”€โ”€ โ–บ:4[2]:anon: - โ””โ”€โ”€ ยท7b9f260 (โŒ‚|โœ“|1) - โ”œโ”€โ”€ โ†’:5: (main โ†’:2:) - โ””โ”€โ”€ โ†’:6: (A) - -"#]] - ); - - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -โŒ‚:DETACHED <> โœ“refs/remotes/origin/mainโ‡ฃ3 on 79bbb29 -โ””โ”€โ”€ โ‰ก:anon: on 4b3e5a8 {1} - โ””โ”€โ”€ :anon: - โ”œโ”€โ”€ ยทd0df794 (โœ“) - โ”œโ”€โ”€ ยท09c6e08 (โœ“) - โ””โ”€โ”€ ยท7b9f260 (โœ“) - -"#]] - ); - - Ok(()) -} - -#[test] -fn integrated_tips_do_not_stop_early() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/two-segments-one-integrated")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 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 -| | * 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 - -"#]] - .raw() - ); - - add_workspace(&mut meta); - // Thanks to the remote `main` is searched for by the entrypoint. - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท4077353 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:4[1]:B -โ”‚ โ”œโ”€โ”€ ยท6b1a13b (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ ยท03ad472 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:5[2]:A -โ”‚ โ”œโ”€โ”€ ยท79bbb29 (โŒ‚|๐Ÿ˜|โœ“|01) -โ”‚ โ”œโ”€โ”€ ยทfc98174 (โŒ‚|๐Ÿ˜|โœ“|01) -โ”‚ โ”œโ”€โ”€ ยทa381df5 (โŒ‚|๐Ÿ˜|โœ“|01) -โ”‚ โ””โ”€โ”€ ยท777b552 (โŒ‚|๐Ÿ˜|โœ“|01) -โ”‚ โ””โ”€โ”€ โ–บ:6[3]:anon: -โ”‚ โ””โ”€โ”€ ยทce4a760 (โŒ‚|๐Ÿ˜|โœ“|01) -โ”‚ โ”œโ”€โ”€ โ–บ:7[5]:anon: -โ”‚ โ”‚ โ””โ”€โ”€ ยท01d0e1e (โŒ‚|๐Ÿ˜|โœ“|01) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:2[6]:main <> origin/main โ†’:1: -โ”‚ โ”‚ โ”œโ”€โ”€ ยท4b3e5a8 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ”‚ โ”œโ”€โ”€ ยท34d0715 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทeb5f731 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ””โ”€โ”€ โ–บ:8[4]:A-feat -โ”‚ โ”œโ”€โ”€ ยทfea59b5 (โŒ‚|๐Ÿ˜|โœ“|01) -โ”‚ โ””โ”€โ”€ ยท4deea74 (โŒ‚|๐Ÿ˜|โœ“|01) -โ”‚ โ””โ”€โ”€ โ†’:7: -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ”œโ”€โ”€ ๐ŸŸฃd0df794 (โœ“) - โ””โ”€โ”€ ๐ŸŸฃ09c6e08 (โœ“) - โ””โ”€โ”€ โ–บ:3[1]:anon: - โ””โ”€โ”€ ๐ŸŸฃ7b9f260 (โœ“) - โ”œโ”€โ”€ โ†’:2: (main โ†’:1:) - โ””โ”€โ”€ โ†’:5: (A) - -"#]] - ); - - // This search discovers the whole workspace, without the integrated one. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ3 on 79bbb29 -โ””โ”€โ”€ โ‰ก:B on 79bbb29 - โ””โ”€โ”€ :B - โ”œโ”€โ”€ ยท6b1a13b (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ยท03ad472 (๐Ÿ˜๏ธ) - -"#]] - ); - - // However, we can specify an additional/old target segment to show integrated portions as well. - let graph = Graph::from_head( - &repo, - &*meta, - project_meta(&*meta), - standard_options_with_extra_target(&repo, "main"), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ3 on 4b3e5a8 -โ””โ”€โ”€ โ‰ก:B on 4b3e5a8 - โ”œโ”€โ”€ :B - โ”‚ โ”œโ”€โ”€ ยท6b1a13b (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ ยท03ad472 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :A - โ”œโ”€โ”€ ยท79bbb29 (๐Ÿ˜๏ธ|โœ“) - โ”œโ”€โ”€ ยทfc98174 (๐Ÿ˜๏ธ|โœ“) - โ”œโ”€โ”€ ยทa381df5 (๐Ÿ˜๏ธ|โœ“) - โ”œโ”€โ”€ ยท777b552 (๐Ÿ˜๏ธ|โœ“) - โ”œโ”€โ”€ ยทce4a760 (๐Ÿ˜๏ธ|โœ“) - โ””โ”€โ”€ ยท01d0e1e (๐Ÿ˜๏ธ|โœ“) - -"#]] - ); - - // When looking from an integrated branch within the workspace, and without limit - // the limit isn't respected, and we still know the whole workspace. - let (id, ref_name) = id_at(&repo, "A"); - let graph = Graph::from_commit_traversal( - id, - ref_name.clone(), - &*meta, - project_meta(&*meta), - standard_options(), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท4077353 (โŒ‚|๐Ÿ˜) -โ”‚ โ””โ”€โ”€ โ–บ:5[1]:B -โ”‚ โ”œโ”€โ”€ ยท6b1a13b (โŒ‚|๐Ÿ˜) -โ”‚ โ””โ”€โ”€ ยท03ad472 (โŒ‚|๐Ÿ˜) -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[2]:A -โ”‚ โ”œโ”€โ”€ ยท79bbb29 (โŒ‚|๐Ÿ˜|โœ“|01) -โ”‚ โ”œโ”€โ”€ ยทfc98174 (โŒ‚|๐Ÿ˜|โœ“|01) -โ”‚ โ”œโ”€โ”€ ยทa381df5 (โŒ‚|๐Ÿ˜|โœ“|01) -โ”‚ โ””โ”€โ”€ ยท777b552 (โŒ‚|๐Ÿ˜|โœ“|01) -โ”‚ โ””โ”€โ”€ โ–บ:6[3]:anon: -โ”‚ โ””โ”€โ”€ ยทce4a760 (โŒ‚|๐Ÿ˜|โœ“|01) -โ”‚ โ”œโ”€โ”€ โ–บ:7[5]:anon: -โ”‚ โ”‚ โ””โ”€โ”€ ยท01d0e1e (โŒ‚|๐Ÿ˜|โœ“|01) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:3[6]:main <> origin/main โ†’:2: -โ”‚ โ”‚ โ”œโ”€โ”€ ยท4b3e5a8 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ”‚ โ”œโ”€โ”€ ยท34d0715 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทeb5f731 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ””โ”€โ”€ โ–บ:8[4]:A-feat -โ”‚ โ”œโ”€โ”€ ยทfea59b5 (โŒ‚|๐Ÿ˜|โœ“|01) -โ”‚ โ””โ”€โ”€ ยท4deea74 (โŒ‚|๐Ÿ˜|โœ“|01) -โ”‚ โ””โ”€โ”€ โ†’:7: -โ””โ”€โ”€ โ–บ:2[0]:origin/main โ†’:3: - โ”œโ”€โ”€ ๐ŸŸฃd0df794 (โœ“) - โ””โ”€โ”€ ๐ŸŸฃ09c6e08 (โœ“) - โ””โ”€โ”€ โ–บ:4[1]:anon: - โ””โ”€โ”€ ๐ŸŸฃ7b9f260 (โœ“) - โ”œโ”€โ”€ โ†’:3: (main โ†’:2:) - โ””โ”€โ”€ โ†’:0: (A) - -"#]] - ); - - // The entrypoint isn't contained in the managed workspace anymore, so it's a standalone - // single-branch view. Target context is preserved, so integrated commits are pruned while - // the branch container remains visible. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -โŒ‚:A <> โœ“refs/remotes/origin/mainโ‡ฃ3 -โ””โ”€โ”€ โ‰ก:A on 4b3e5a8 {1} - โ””โ”€โ”€ :A - -"#]] - ); - - // When converting to a workspace, we are still aware of the workspace membership as long as - // the lower bound of the workspace includes it. - let graph = Graph::from_commit_traversal( - id, - ref_name.clone(), - &*meta, - project_meta(&*meta), - standard_options_with_extra_target(&repo, "main"), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ3 on 4b3e5a8 -โ””โ”€โ”€ โ‰ก:B on 4b3e5a8 - โ”œโ”€โ”€ :B - โ”‚ โ”œโ”€โ”€ ยท6b1a13b (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ ยท03ad472 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ‘‰:A - โ”œโ”€โ”€ ยท79bbb29 (๐Ÿ˜๏ธ|โœ“) - โ”œโ”€โ”€ ยทfc98174 (๐Ÿ˜๏ธ|โœ“) - โ”œโ”€โ”€ ยทa381df5 (๐Ÿ˜๏ธ|โœ“) - โ”œโ”€โ”€ ยท777b552 (๐Ÿ˜๏ธ|โœ“) - โ”œโ”€โ”€ ยทce4a760 (๐Ÿ˜๏ธ|โœ“) - โ””โ”€โ”€ ยท01d0e1e (๐Ÿ˜๏ธ|โœ“) - -"#]] - ); - - let (id, ref_name) = id_at(&repo, "main"); - let graph = Graph::from_commit_traversal( - id, - ref_name.clone(), - &*meta, - project_meta(&*meta), - standard_options(), - )? - .validated()?; - // When the branch is below the forkpoint, the workspace also isn't shown anymore. - // The downgraded branch view keeps target context and prunes integrated base commits. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -โŒ‚:main <> โœ“refs/remotes/origin/mainโ‡ฃ3 -โ””โ”€โ”€ โ‰ก:main <> origin/mainโ‡ฃ3 {1} - โ””โ”€โ”€ :main <> origin/mainโ‡ฃ3 - โ”œโ”€โ”€ ๐ŸŸฃd0df794 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ09c6e08 (โœ“) - โ””โ”€โ”€ ๐ŸŸฃ7b9f260 (โœ“) - -"#]] - ); - - let id = id_by_rev(&repo, "main~1"); - let graph = - Graph::from_commit_traversal(id, None, &*meta, project_meta(&*meta), standard_options())? - .validated()?; - // Detached states are also possible. They keep the anonymous container while - // preserving target context and pruning integrated commits. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -โŒ‚:DETACHED <> โœ“refs/remotes/origin/mainโ‡ฃ3 -โ””โ”€โ”€ โ‰ก:anon: {1} - โ””โ”€โ”€ :anon: - -"#]] - ); - Ok(()) -} - -#[test] -fn workspace_without_target_can_see_remote() -> anyhow::Result<()> { - let (mut repo, _) = read_only_in_memory_scenario("ws/main-with-remote-and-workspace-ref")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 956a3de (origin/main) on-remote-only -* 3183e43 (HEAD -> main, gitbutler/workspace) M1 - -"#]] - ); - - // Use an in-memory version directly as vb.toml can't bring in remote branches. - let mut meta = InMemoryRefMetadata::default(); - let ws_ref = "refs/heads/gitbutler/workspace".try_into()?; - let mut ws = meta.workspace(ws_ref)?; - for (idx, ref_name) in ["refs/heads/main", "refs/remotes/origin/main"] - .into_iter() - .enumerate() - { - ws.stacks.push(WorkspaceStack { - id: StackId::from_number_for_testing(idx as u128), - branches: vec![WorkspaceStackBranch { - ref_name: ref_name.try_into()?, - archived: false, - }], - workspacecommit_relation: WorkspaceCommitRelation::Merged, - }); - meta.branches.push(( - ref_name.try_into()?, - but_core::ref_metadata::Branch::default(), - )) - } - meta.set_workspace(&ws)?; - - let graph = - Graph::from_head(&repo, &meta, project_meta(&meta), standard_options())?.validated()?; - // Main is a normal branch, and its remote is known. - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™โ–บ:0[1]:main[๐ŸŒณ] <> origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|1) -โ””โ”€โ”€ ๐Ÿ“™โ–บ:2[0]:origin/main โ†’:0: - โ””โ”€โ”€ ยท956a3de (โŒ‚) - โ””โ”€โ”€ โ†’:0: (main[๐ŸŒณ] โ†’:2:) - -"#]] - ); - - let ws = graph.into_workspace()?; - // The workspace shows the remote commit, there is nothing special about the target. - snapbox::assert_data_eq!( - graph_workspace(&ws).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:main[๐ŸŒณ] <> origin/mainโ‡ก1 {0} - โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:main[๐ŸŒณ] <> origin/mainโ‡ก1 - โ””โ”€โ”€ ยท3183e43 (๐Ÿ˜๏ธ) - -"#]] - ); - - // If the remote isn't setup officially, deduction still works as we find - // symbolic remote names for deduction in workspace ref names as well. - repo.config_snapshot_mut() - .remove_section("branch", Some("main".into())); - let graph = ws - .graph - .redo_traversal_with_overlay(&repo, &meta, Overlay::default())?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™โ–บ:0[1]:main[๐ŸŒณ] <> origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|1) -โ””โ”€โ”€ ๐Ÿ“™โ–บ:2[0]:origin/main โ†’:0: - โ””โ”€โ”€ ยท956a3de (โŒ‚) - โ””โ”€โ”€ โ†’:0: (main[๐ŸŒณ] โ†’:2:) - -"#]] - ); - - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:main[๐ŸŒณ] <> origin/mainโ‡ก1 {0} - โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:main[๐ŸŒณ] <> origin/mainโ‡ก1 - โ””โ”€โ”€ ยท3183e43 (๐Ÿ˜๏ธ) - -"#]] - ); - 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-without-remote")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 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 -| | * 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 - -"#]] - .raw() - ); - 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, - project_meta(&*meta), - standard_options().with_limit_hint(0), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] - โ””โ”€โ”€ โœ‚ยท4077353 (โŒ‚|๐Ÿ˜|1) - -"#]] - ); - // The commit in the workspace branch is always ignored and is expected to be the workspace merge commit. - // So nothing to show here. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ: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. - // and there is no deductible remote relationship between origin/main and main (no remote not configured). - // Then the traversal ends on integrated branches as `main` isn't a target. - let graph = Graph::from_head( - &repo, - &*meta, - project_meta(&*meta), - standard_options().with_limit_hint(0), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท4077353 (โŒ‚|๐Ÿ˜|1) -โ”‚ โ””โ”€โ”€ โ–บ:3[1]:B -โ”‚ โ”œโ”€โ”€ ยท6b1a13b (โŒ‚|๐Ÿ˜|1) -โ”‚ โ””โ”€โ”€ ยท03ad472 (โŒ‚|๐Ÿ˜|1) -โ”‚ โ””โ”€โ”€ โ–บ:5[2]:A -โ”‚ โ”œโ”€โ”€ ยท79bbb29 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”œโ”€โ”€ ยทfc98174 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ โœ‚ยทa381df5 (โŒ‚|๐Ÿ˜|โœ“|1) -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:4: - โ”œโ”€โ”€ ๐ŸŸฃd0df794 (โœ“) - โ””โ”€โ”€ ๐ŸŸฃ09c6e08 (โœ“) - โ””โ”€โ”€ โ–บ:2[1]:anon: - โ””โ”€โ”€ ๐ŸŸฃ7b9f260 (โœ“) - โ”œโ”€โ”€ โ–บ:4[2]:main <> origin/main โ†’:1: - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ4b3e5a8 (โœ“) - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ34d0715 (โœ“) - โ”‚ โ””โ”€โ”€ ๐Ÿ๐ŸŸฃeb5f731 (โœ“) - โ””โ”€โ”€ โ†’:5: (A) - -"#]] - ); - - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ6 on 79bbb29 -โ””โ”€โ”€ โ‰ก:B on 79bbb29 - โ””โ”€โ”€ :B - โ”œโ”€โ”€ ยท6b1a13b (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ยท03ad472 (๐Ÿ˜๏ธ) - -"#]] - ); - - Ok(()) -} - -#[test] -fn three_branches_one_advanced_ws_commit_advanced_fully_pushed_empty_dependent() --> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario( - "ws/three-branches-one-advanced-ws-commit-advanced-fully-pushed-empty-dependent", - )?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* f8f33a7 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -* cbc6713 (origin/advanced-lane, on-top-of-dependent, dependent, advanced-lane) change -* fafd9d0 (origin/main, main, lane) init - -"#]] - ); - - add_workspace(&mut meta); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยทf8f33a7 (โŒ‚|๐Ÿ˜|001) -โ”‚ โ””โ”€โ”€ โ–บ:4[1]:advanced-lane <> origin/advanced-lane โ†’:3: -โ”‚ โ””โ”€โ”€ ยทcbc6713 (โŒ‚|๐Ÿ˜|101) โ–บdependent, โ–บon-top-of-dependent -โ”‚ โ””โ”€โ”€ โ–บ:2[2]:main <> origin/main โ†’:1: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|111) โ–บlane -โ”œโ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ โ†’:2: (main โ†’:1:) -โ””โ”€โ”€ โ–บ:3[0]:origin/advanced-lane โ†’:4: - โ””โ”€โ”€ โ†’:4: (advanced-lane โ†’:3:) - -"#]] - ); - - // By default, the advanced lane is simply frozen as its remote contains the commit. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:advanced-lane <> origin/advanced-lane on fafd9d0 - โ””โ”€โ”€ :advanced-lane <> origin/advanced-lane - โ””โ”€โ”€ โ„๏ธcbc6713 (๐Ÿ˜๏ธ) โ–บdependent, โ–บon-top-of-dependent - -"#]] - ); - - add_stack_with_segments( - &mut meta, - 1, - "dependent", - StackState::InWorkspace, - &["advanced-lane"], - ); - - // Lanes are properly ordered - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยทf8f33a7 (โŒ‚|๐Ÿ˜|001) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:5[1]:dependent -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:6[2]:advanced-lane <> origin/advanced-lane โ†’:4: -โ”‚ โ””โ”€โ”€ ยทcbc6713 (โŒ‚|๐Ÿ˜|101) โ–บon-top-of-dependent -โ”‚ โ””โ”€โ”€ โ–บ:2[3]:main <> origin/main โ†’:1: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|111) โ–บlane -โ”œโ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ โ†’:2: (main โ†’:1:) -โ””โ”€โ”€ โ–บ:4[0]:origin/advanced-lane โ†’:6: - โ””โ”€โ”€ โ†’:6: (advanced-lane โ†’:4:) - -"#]] - ); - - // When putting the dependent branch on top as empty segment, the frozen state is retained. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:dependent on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:dependent - โ””โ”€โ”€ ๐Ÿ“™:advanced-lane <> origin/advanced-lane - โ””โ”€โ”€ โ„๏ธcbc6713 (๐Ÿ˜๏ธ) โ–บon-top-of-dependent - -"#]] - ); - 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")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[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); - // It sees the entire history as it had to find `main`. - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] - โ””โ”€โ”€ โ–บ:1[1]:origin/main โ†’:2: - โ”œโ”€โ”€ ยท2cde30a (โŒ‚|๐Ÿ˜|โœ“|01) โ–บA, โ–บB, โ–บC, โ–บD, โ–บE, โ–บF - โ”œโ”€โ”€ ยท1c938f4 (โŒ‚|๐Ÿ˜|โœ“|01) - โ”œโ”€โ”€ ยทb82769f (โŒ‚|๐Ÿ˜|โœ“|01) - โ”œโ”€โ”€ ยท988032f (โŒ‚|๐Ÿ˜|โœ“|01) - โ””โ”€โ”€ ยทcd5b655 (โŒ‚|๐Ÿ˜|โœ“|01) - โ””โ”€โ”€ โ–บ:2[2]:main <> origin/main โ†’:1: - โ””โ”€โ”€ ๐Ÿยท2be54cd (โŒ‚|๐Ÿ˜|โœ“|11) - -"#]] - ); - // Workspace is empty as everything is integrated. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 2cde30a - -"#]] - ); - - add_stack_with_segments(&mut meta, 0, "C", StackState::InWorkspace, &["B", "A"]); - add_stack_with_segments(&mut meta, 1, "D", StackState::InWorkspace, &["E", "F"]); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] - โ”œโ”€โ”€ ๐Ÿ“™โ–บ:3[1]:C - โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:4[2]:B - โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:5[3]:A - โ”‚ โ””โ”€โ”€ โ–บ:1[4]:origin/main โ†’:2: - โ”‚ โ”œโ”€โ”€ ยท2cde30a (โŒ‚|๐Ÿ˜|โœ“|01) - โ”‚ โ”œโ”€โ”€ ยท1c938f4 (โŒ‚|๐Ÿ˜|โœ“|01) - โ”‚ โ”œโ”€โ”€ ยทb82769f (โŒ‚|๐Ÿ˜|โœ“|01) - โ”‚ โ”œโ”€โ”€ ยท988032f (โŒ‚|๐Ÿ˜|โœ“|01) - โ”‚ โ””โ”€โ”€ ยทcd5b655 (โŒ‚|๐Ÿ˜|โœ“|01) - โ”‚ โ””โ”€โ”€ โ–บ:2[5]:main <> origin/main โ†’:1: - โ”‚ โ””โ”€โ”€ ๐Ÿยท2be54cd (โŒ‚|๐Ÿ˜|โœ“|11) - โ””โ”€โ”€ ๐Ÿ“™โ–บ:6[1]:D - โ””โ”€โ”€ ๐Ÿ“™โ–บ:7[2]:E - โ””โ”€โ”€ ๐Ÿ“™โ–บ:8[3]:F - โ””โ”€โ”€ โ†’:1: (origin/main โ†’:2:) - -"#]] - ); - - // Empty stack segments on top of integrated portions will show, and nothing integrated shows. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 2cde30a -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 2cde30a {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:D on 2cde30a {1} - โ”œโ”€โ”€ ๐Ÿ“™:D - โ”œโ”€โ”€ ๐Ÿ“™:E - โ””โ”€โ”€ ๐Ÿ“™:F - -"#]] - ); - - // However, when passing an additional old position of the target, we can show the now-integrated parts. - // The stacks will always be created on top of the integrated segments as that's where their references are - // (these segments are never conjured up out of thin air). - let graph = Graph::from_head( - &repo, - &*meta, - project_meta(&*meta), - standard_options_with_extra_target(&repo, "main"), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 2be54cd -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 2be54cd {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ”œโ”€โ”€ ยท2cde30a (๐Ÿ˜๏ธ|โœ“) -โ”‚ โ”œโ”€โ”€ ยท1c938f4 (๐Ÿ˜๏ธ|โœ“) -โ”‚ โ”œโ”€โ”€ ยทb82769f (๐Ÿ˜๏ธ|โœ“) -โ”‚ โ”œโ”€โ”€ ยท988032f (๐Ÿ˜๏ธ|โœ“) -โ”‚ โ””โ”€โ”€ ยทcd5b655 (๐Ÿ˜๏ธ|โœ“) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:D on 2be54cd {1} - โ”œโ”€โ”€ ๐Ÿ“™:D - โ”œโ”€โ”€ ๐Ÿ“™:E - โ””โ”€โ”€ ๐Ÿ“™:F - โ”œโ”€โ”€ ยท2cde30a (๐Ÿ˜๏ธ|โœ“) - โ”œโ”€โ”€ ยท1c938f4 (๐Ÿ˜๏ธ|โœ“) - โ”œโ”€โ”€ ยทb82769f (๐Ÿ˜๏ธ|โœ“) - โ”œโ”€โ”€ ยท988032f (๐Ÿ˜๏ธ|โœ“) - โ””โ”€โ”€ ยทcd5b655 (๐Ÿ˜๏ธ|โœ“) - -"#]] - ); - 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")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[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 - -"#]] - .raw() - ); - - add_workspace(&mut meta); - let (main_id, main_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( - main_id, - main_ref_name.clone(), - &*meta, - project_meta(&*meta), - standard_options(), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท41ed0e4 (โŒ‚|๐Ÿ˜) -โ”‚ โ””โ”€โ”€ โ–บ:3[2]:workspace -โ”‚ โ””โ”€โ”€ ยท9730cbf (โŒ‚|๐Ÿ˜|โœ“) -โ”‚ โ”œโ”€โ”€ โ–บ:6[3]:main-to-workspace -โ”‚ โ”‚ โ””โ”€โ”€ ยทdc7ab57 (โŒ‚|๐Ÿ˜|โœ“) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:8[5]:anon: -โ”‚ โ”‚ โ”œโ”€โ”€ ยทc056b75 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ”œโ”€โ”€ ยทf49c977 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ”œโ”€โ”€ ยท7b7ebb2 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ”œโ”€โ”€ ยทdca4960 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ”œโ”€โ”€ ยท11c29b8 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ”œโ”€โ”€ ยทc32dd03 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ”œโ”€โ”€ ยทb625665 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ”œโ”€โ”€ ยทa821094 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ”œโ”€โ”€ ยทbce0c5e (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ โ–บ:7[3]:long-main-to-workspace -โ”‚ โ”œโ”€โ”€ ยท77f31a0 (โŒ‚|๐Ÿ˜|โœ“) -โ”‚ โ”œโ”€โ”€ ยทeb17e31 (โŒ‚|๐Ÿ˜|โœ“) -โ”‚ โ”œโ”€โ”€ ยทfe2046b (โŒ‚|๐Ÿ˜|โœ“) -โ”‚ โ””โ”€โ”€ ยท5532ef5 (โŒ‚|๐Ÿ˜|โœ“) -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[4]:main <> origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ ยท2438292 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ โ†’:8: -โ””โ”€โ”€ โ–บ:2[0]:origin/main โ†’:0: - โ””โ”€โ”€ ๐ŸŸฃ232ed06 (โœ“) - โ”œโ”€โ”€ โ–บ:4[1]:workspace-to-target - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃabcfd9a (โœ“) - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃbc86eba (โœ“) - โ”‚ โ””โ”€โ”€ ๐ŸŸฃc7ae303 (โœ“) - โ”‚ โ””โ”€โ”€ โ†’:3: (workspace) - โ””โ”€โ”€ โ–บ:5[1]:long-workspace-to-target - โ”œโ”€โ”€ ๐ŸŸฃ9e2a79e (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃfdeaa43 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ30565ee (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ0c1c23a (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ56d152c (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃe6e1360 (โœ“) - โ””โ”€โ”€ ๐ŸŸฃ1a22a39 (โœ“) - โ””โ”€โ”€ โ†’:3: (workspace) - -"#]] - ); - // Entrypoint is outside of the managed workspace, so it is projected as a - // single-branch view. Target context is preserved and integrated commits below - // the target trunk are pruned. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -โŒ‚:main <> โœ“refs/remotes/origin/mainโ‡ฃ11 -โ””โ”€โ”€ โ‰ก:main <> origin/mainโ‡ฃ11 {1} - โ””โ”€โ”€ :main <> origin/mainโ‡ฃ11 - โ”œโ”€โ”€ ๐ŸŸฃ232ed06 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃabcfd9a (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃbc86eba (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃc7ae303 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ9e2a79e (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃfdeaa43 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ30565ee (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ0c1c23a (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ56d152c (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃe6e1360 (โœ“) - โ””โ”€โ”€ ๐ŸŸฃ1a22a39 (โœ“) - -"#]] - ); - - // 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( - main_id, - main_ref_name, - &*meta, - project_meta(&*meta), - standard_options().with_limit_hint(1), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท41ed0e4 (โŒ‚|๐Ÿ˜) -โ”‚ โ””โ”€โ”€ โ–บ:3[2]:workspace -โ”‚ โ””โ”€โ”€ ยท9730cbf (โŒ‚|๐Ÿ˜|โœ“) -โ”‚ โ”œโ”€โ”€ โ–บ:6[3]:main-to-workspace -โ”‚ โ”‚ โ””โ”€โ”€ ยทdc7ab57 (โŒ‚|๐Ÿ˜|โœ“) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:8[5]:anon: -โ”‚ โ”‚ โ”œโ”€โ”€ ยทc056b75 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ”œโ”€โ”€ ยทf49c977 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ”œโ”€โ”€ ยท7b7ebb2 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ”œโ”€โ”€ ยทdca4960 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ””โ”€โ”€ โœ‚ยท11c29b8 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ โ–บ:7[3]:long-main-to-workspace -โ”‚ โ”œโ”€โ”€ ยท77f31a0 (โŒ‚|๐Ÿ˜|โœ“) -โ”‚ โ”œโ”€โ”€ ยทeb17e31 (โŒ‚|๐Ÿ˜|โœ“) -โ”‚ โ”œโ”€โ”€ ยทfe2046b (โŒ‚|๐Ÿ˜|โœ“) -โ”‚ โ””โ”€โ”€ ยท5532ef5 (โŒ‚|๐Ÿ˜|โœ“) -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[4]:main <> origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ ยท2438292 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ โ†’:8: -โ””โ”€โ”€ โ–บ:2[0]:origin/main โ†’:0: - โ””โ”€โ”€ ๐ŸŸฃ232ed06 (โœ“) - โ”œโ”€โ”€ โ–บ:4[1]:workspace-to-target - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃabcfd9a (โœ“) - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃbc86eba (โœ“) - โ”‚ โ””โ”€โ”€ ๐ŸŸฃc7ae303 (โœ“) - โ”‚ โ””โ”€โ”€ โ†’:3: (workspace) - โ””โ”€โ”€ โ–บ:5[1]:long-workspace-to-target - โ”œโ”€โ”€ ๐ŸŸฃ9e2a79e (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃfdeaa43 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ30565ee (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ0c1c23a (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ56d152c (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃe6e1360 (โœ“) - โ””โ”€โ”€ ๐ŸŸฃ1a22a39 (โœ“) - โ””โ”€โ”€ โ†’:3: (workspace) - -"#]] - ); - // The limit is visible as well. Target context is preserved in the downgraded - // branch view, so integrated local/base commits are pruned. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -โŒ‚:main <> โœ“refs/remotes/origin/mainโ‡ฃ11 -โ””โ”€โ”€ โ‰ก:main <> origin/mainโ‡ฃ11 {1} - โ””โ”€โ”€ :main <> origin/mainโ‡ฃ11 - โ”œโ”€โ”€ ๐ŸŸฃ232ed06 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃabcfd9a (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃbc86eba (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃc7ae303 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ9e2a79e (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃfdeaa43 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ30565ee (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ0c1c23a (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ56d152c (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃe6e1360 (โœ“) - โ””โ”€โ”€ ๐ŸŸฃ1a22a39 (โœ“) - -"#]] - ); - - // 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, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท41ed0e4 (โŒ‚|๐Ÿ˜|1) -โ”‚ โ””โ”€โ”€ โ–บ:2[2]:workspace -โ”‚ โ””โ”€โ”€ ยท9730cbf (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”œโ”€โ”€ โ–บ:5[3]:main-to-workspace -โ”‚ โ”‚ โ””โ”€โ”€ ยทdc7ab57 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:8[5]:anon: -โ”‚ โ”‚ โ”œโ”€โ”€ ยทc056b75 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ”œโ”€โ”€ ยทf49c977 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ”œโ”€โ”€ ยท7b7ebb2 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ”œโ”€โ”€ ยทdca4960 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ”œโ”€โ”€ ยท11c29b8 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ”œโ”€โ”€ ยทc32dd03 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ”œโ”€โ”€ ยทb625665 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ””โ”€โ”€ โœ‚ยทa821094 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ โ–บ:6[3]:long-main-to-workspace -โ”‚ โ”œโ”€โ”€ ยท77f31a0 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”œโ”€โ”€ ยทeb17e31 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”œโ”€โ”€ ยทfe2046b (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ ยท5532ef5 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ โ–บ:7[4]:main <> origin/main โ†’:1: -โ”‚ โ””โ”€โ”€ ยท2438292 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ โ†’:8: -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:7: - โ””โ”€โ”€ ๐ŸŸฃ232ed06 (โœ“) - โ”œโ”€โ”€ โ–บ:3[1]:workspace-to-target - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃabcfd9a (โœ“) - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃbc86eba (โœ“) - โ”‚ โ””โ”€โ”€ ๐ŸŸฃc7ae303 (โœ“) - โ”‚ โ””โ”€โ”€ โ†’:2: (workspace) - โ””โ”€โ”€ โ–บ:4[1]:long-workspace-to-target - โ”œโ”€โ”€ ๐ŸŸฃ9e2a79e (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃfdeaa43 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ30565ee (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ0c1c23a (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ56d152c (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃe6e1360 (โœ“) - โ””โ”€โ”€ ๐ŸŸฃ1a22a39 (โœ“) - โ””โ”€โ”€ โ†’:2: (workspace) - -"#]] - ); - - // Everything is integrated, nothing to see here. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ11 on 9730cbf - -"#]] - ); - Ok(()) -} - -#[test] -fn remote_far_in_ancestry() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/remote-far-in-ancestry")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 9412ebd (HEAD -> gitbutler/workspace) GitButler Workspace Commit -* 8407093 (A) A3 -* 7dfaa0c A2 -* 544e458 A1 -* 685d644 (origin/main, main) M12 -* cafdb27 M11 -* c056b75 M10 -* f49c977 M9 -* 7b7ebb2 M8 -* dca4960 M7 -* 11c29b8 M6 -* c32dd03 M5 -* b625665 M4 -* a821094 M3 -* bce0c5e M2 -| * 975754f (origin/A) R3 -| * f48ff69 R2 -|/ -* 3183e43 M1 - -"#]] - ); - - add_workspace(&mut meta); - let graph = Graph::from_head( - &repo, - &*meta, - project_meta(&*meta), - standard_options().with_limit_hint(1), - )? - .validated()?; - // It's critical that the main branch isn't cut off and the local and remote part find each other, - // or else the remote part will go on forever create a lot of issues for those who want to display - // all these incorrectly labeled commits. - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท9412ebd (โŒ‚|๐Ÿ˜|0001) -โ”‚ โ””โ”€โ”€ โ–บ:3[1]:A <> origin/A โ†’:4: -โ”‚ โ”œโ”€โ”€ ยท8407093 (โŒ‚|๐Ÿ˜|0101) -โ”‚ โ”œโ”€โ”€ ยท7dfaa0c (โŒ‚|๐Ÿ˜|0101) -โ”‚ โ””โ”€โ”€ ยท544e458 (โŒ‚|๐Ÿ˜|0101) -โ”‚ โ””โ”€โ”€ โ–บ:2[2]:main <> origin/main โ†’:1: -โ”‚ โ”œโ”€โ”€ ยท685d644 (โŒ‚|๐Ÿ˜|โœ“|0111) -โ”‚ โ”œโ”€โ”€ ยทcafdb27 (โŒ‚|๐Ÿ˜|โœ“|0111) -โ”‚ โ”œโ”€โ”€ ยทc056b75 (โŒ‚|๐Ÿ˜|โœ“|0111) -โ”‚ โ”œโ”€โ”€ ยทf49c977 (โŒ‚|๐Ÿ˜|โœ“|0111) -โ”‚ โ”œโ”€โ”€ ยท7b7ebb2 (โŒ‚|๐Ÿ˜|โœ“|0111) -โ”‚ โ”œโ”€โ”€ ยทdca4960 (โŒ‚|๐Ÿ˜|โœ“|0111) -โ”‚ โ”œโ”€โ”€ ยท11c29b8 (โŒ‚|๐Ÿ˜|โœ“|0111) -โ”‚ โ”œโ”€โ”€ ยทc32dd03 (โŒ‚|๐Ÿ˜|โœ“|0111) -โ”‚ โ”œโ”€โ”€ ยทb625665 (โŒ‚|๐Ÿ˜|โœ“|0111) -โ”‚ โ”œโ”€โ”€ ยทa821094 (โŒ‚|๐Ÿ˜|โœ“|0111) -โ”‚ โ””โ”€โ”€ ยทbce0c5e (โŒ‚|๐Ÿ˜|โœ“|0111) -โ”‚ โ””โ”€โ”€ โ–บ:5[3]:anon: -โ”‚ โ””โ”€โ”€ ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“|1111) -โ”œโ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ โ†’:2: (main โ†’:1:) -โ””โ”€โ”€ โ–บ:4[0]:origin/A โ†’:3: - โ”œโ”€โ”€ ๐ŸŸฃ975754f (0x0|1000) - โ””โ”€โ”€ ๐ŸŸฃf48ff69 (0x0|1000) - โ””โ”€โ”€ โ†’:5: - -"#]] - ); - - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 685d644 -โ””โ”€โ”€ โ‰ก:A <> origin/Aโ‡ก3โ‡ฃ2 on 685d644 - โ””โ”€โ”€ :A <> origin/Aโ‡ก3โ‡ฃ2 - โ”œโ”€โ”€ ๐ŸŸฃ975754f - โ”œโ”€โ”€ ๐ŸŸฃf48ff69 - โ”œโ”€โ”€ ยท8407093 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ยท7dfaa0c (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ยท544e458 (๐Ÿ˜๏ธ) - -"#]] - ); - 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")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[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 (B, A) M1 - -"#]] - .raw() - ); - - 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, - project_meta(&*meta), - standard_options(), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยทf514495 (โŒ‚|๐Ÿ˜) -โ”‚ โ””โ”€โ”€ โ–บ:3[3]:workspace -โ”‚ โ””โ”€โ”€ ยทc9120f1 (โŒ‚|๐Ÿ˜|โœ“) -โ”‚ โ”œโ”€โ”€ โ–บ:4[4]:main-to-workspace -โ”‚ โ”‚ โ””โ”€โ”€ ยท1126587 (โŒ‚|๐Ÿ˜|โœ“) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:6[6]:anon: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“|1) โ–บA, โ–บB -โ”‚ โ””โ”€โ”€ โ–บ:5[4]:long-main-to-workspace -โ”‚ โ”œโ”€โ”€ ยทb39c7ec (โŒ‚|๐Ÿ˜|โœ“) -โ”‚ โ”œโ”€โ”€ ยท2983a97 (โŒ‚|๐Ÿ˜|โœ“) -โ”‚ โ”œโ”€โ”€ ยท144ea85 (โŒ‚|๐Ÿ˜|โœ“) -โ”‚ โ””โ”€โ”€ ยท5aecfd2 (โŒ‚|๐Ÿ˜|โœ“) -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[5]:main <> origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ ยทbce0c5e (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ โ†’:6: -โ””โ”€โ”€ โ–บ:2[0]:origin/main โ†’:0: - โ”œโ”€โ”€ ๐ŸŸฃ024f837 (โœ“) โ–บlong-workspace-to-target - โ”œโ”€โ”€ ๐ŸŸฃ64a8284 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃb72938c (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ9ccbf6f (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ5fa4905 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ43074d3 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ800d4a9 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ742c068 (โœ“) - โ””โ”€โ”€ ๐ŸŸฃfe06afd (โœ“) - โ””โ”€โ”€ โ–บ:7[1]:anon: - โ””โ”€โ”€ ๐ŸŸฃ3027746 (โœ“) - โ”œโ”€โ”€ โ–บ:8[2]:anon: - โ”‚ โ””โ”€โ”€ ๐ŸŸฃf0d2a35 (โœ“) - โ”‚ โ””โ”€โ”€ โ†’:3: (workspace) - โ””โ”€โ”€ โ–บ:9[2]:longer-workspace-to-target - โ”œโ”€โ”€ ๐ŸŸฃedf041f (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃd9f03f6 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ8d1d264 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃfa7ceae (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ95bdbf1 (โœ“) - โ””โ”€โ”€ ๐ŸŸฃ5bac978 (โœ“) - โ””โ”€โ”€ โ†’:4: (main-to-workspace) - -"#]] - ); - // `main` is integrated, but it is the entrypoint, so the branch container is shown. - // With preserved target context, integrated commits below the target trunk are pruned. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -โŒ‚:main <> โœ“refs/remotes/origin/mainโ‡ฃ17 -โ””โ”€โ”€ โ‰ก:main <> origin/mainโ‡ฃ17 {1} - โ””โ”€โ”€ :main <> origin/mainโ‡ฃ17 - โ”œโ”€โ”€ ๐ŸŸฃ024f837 (โœ“) โ–บlong-workspace-to-target - โ”œโ”€โ”€ ๐ŸŸฃ64a8284 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃb72938c (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ9ccbf6f (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ5fa4905 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ43074d3 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ800d4a9 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ742c068 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃfe06afd (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ3027746 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃf0d2a35 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃedf041f (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃd9f03f6 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ8d1d264 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃfa7ceae (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ95bdbf1 (โœ“) - โ””โ”€โ”€ ๐ŸŸฃ5bac978 (โœ“) - -"#]] - ); - - // 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, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยทf514495 (โŒ‚|๐Ÿ˜|1) -โ”‚ โ””โ”€โ”€ โ–บ:2[3]:workspace -โ”‚ โ””โ”€โ”€ ยทc9120f1 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”œโ”€โ”€ โ–บ:3[4]:main-to-workspace -โ”‚ โ”‚ โ””โ”€โ”€ ยท1126587 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:6[6]:anon: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“|1) โ–บA, โ–บB -โ”‚ โ””โ”€โ”€ โ–บ:4[4]:long-main-to-workspace -โ”‚ โ”œโ”€โ”€ ยทb39c7ec (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”œโ”€โ”€ ยท2983a97 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”œโ”€โ”€ ยท144ea85 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ ยท5aecfd2 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ โ–บ:5[5]:main <> origin/main โ†’:1: -โ”‚ โ””โ”€โ”€ ยทbce0c5e (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ””โ”€โ”€ โ†’:6: -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:5: - โ”œโ”€โ”€ ๐ŸŸฃ024f837 (โœ“) โ–บlong-workspace-to-target - โ”œโ”€โ”€ ๐ŸŸฃ64a8284 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃb72938c (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ9ccbf6f (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ5fa4905 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ43074d3 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ800d4a9 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ742c068 (โœ“) - โ””โ”€โ”€ ๐ŸŸฃfe06afd (โœ“) - โ””โ”€โ”€ โ–บ:7[1]:anon: - โ””โ”€โ”€ ๐ŸŸฃ3027746 (โœ“) - โ”œโ”€โ”€ โ–บ:8[2]:anon: - โ”‚ โ””โ”€โ”€ ๐ŸŸฃf0d2a35 (โœ“) - โ”‚ โ””โ”€โ”€ โ†’:2: (workspace) - โ””โ”€โ”€ โ–บ:9[2]:longer-workspace-to-target - โ”œโ”€โ”€ ๐ŸŸฃedf041f (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃd9f03f6 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ8d1d264 (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃfa7ceae (โœ“) - โ”œโ”€โ”€ ๐ŸŸฃ95bdbf1 (โœ“) - โ””โ”€โ”€ ๐ŸŸฃ5bac978 (โœ“) - โ””โ”€โ”€ โ†’:3: (main-to-workspace) - -"#]] - ); - - let ws = graph.into_workspace()?; - // Everything is integrated. - snapbox::assert_data_eq!( - graph_workspace(&ws).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ17 on c9120f1 - -"#]] - ); - - // With a lower base for the target, we see more. - let target_commit_id = repo.rev_parse_single("3183e43")?.detach(); - add_workspace_with_target(&mut meta, target_commit_id); - - let ws = ws - .graph - .redo_traversal_with_overlay(&repo, &*meta, Overlay::default())? - .into_workspace()?; - snapbox::assert_data_eq!( - graph_workspace(&ws).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ17 on c9120f1 - -"#]] - ); - - // We can also add independent virtual branches to that new base. - add_stack(&mut meta, 3, "A", StackState::InWorkspace); - add_stack(&mut meta, 4, "B", StackState::InWorkspace); - let ws = ws - .graph - .redo_traversal_with_overlay(&repo, &*meta, Overlay::default())? - .into_workspace()?; - snapbox::assert_data_eq!( - graph_workspace(&ws).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ17 on c9120f1 - -"#]] - ); - - // We can also add stacked virtual branches to that new base. - meta.data_mut().branches.clear(); - add_workspace_with_target(&mut meta, target_commit_id); - add_stack_with_segments(&mut meta, 3, "A", StackState::InWorkspace, &["B"]); - let ws = ws - .graph - .redo_traversal_with_overlay(&repo, &*meta, Overlay::default())? - .into_workspace()?; - snapbox::assert_data_eq!( - graph_workspace(&ws).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ17 on c9120f1 - -"#]] - ); - Ok(()) -} - -#[test] -fn multi_lane_with_shared_segment_one_integrated() -> anyhow::Result<()> { - let (repo, mut meta) = - read_only_in_memory_scenario("ws/multi-lane-with-shared-segment-one-integrated")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -*-. 2b30d94 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -|\ \ -| | * acdc49a (B) B2 -| | * f0117e0 B1 -* | | 9895054 (D) D1 -* | | de625cc (C) C3 -* | | 23419f8 C2 -* | | 5dc4389 C1 -| |/ -|/| -| | * c08dc6b (origin/main) Merge branch 'A' into soon-remote-main -| | |\ -| | |/ -| |/| -| * | 0bad3af (A) A1 -|/ / -* | d4f537e (shared) S3 -* | b448757 S2 -* | e9a378d S1 -|/ -* 3183e43 (main) M1 - -"#]] - .raw() - ); - - add_workspace(&mut meta); - - let graph = Graph::from_head( - &repo, - &*meta, - project_meta(&*meta), - standard_options_with_extra_target(&repo, "main"), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท2b30d94 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”œโ”€โ”€ โ–บ:3[1]:D -โ”‚ โ”‚ โ””โ”€โ”€ ยท9895054 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:6[2]:C -โ”‚ โ”‚ โ”œโ”€โ”€ ยทde625cc (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ”œโ”€โ”€ ยท23419f8 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ””โ”€โ”€ ยท5dc4389 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:7[3]:shared -โ”‚ โ”‚ โ”œโ”€โ”€ ยทd4f537e (โŒ‚|๐Ÿ˜|โœ“|01) -โ”‚ โ”‚ โ”œโ”€โ”€ ยทb448757 (โŒ‚|๐Ÿ˜|โœ“|01) -โ”‚ โ”‚ โ””โ”€โ”€ ยทe9a378d (โŒ‚|๐Ÿ˜|โœ“|01) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:2[4]:main <> origin/main โ†’:1: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ”œโ”€โ”€ โ–บ:4[1]:A -โ”‚ โ”‚ โ””โ”€โ”€ ยท0bad3af (โŒ‚|๐Ÿ˜|โœ“|01) -โ”‚ โ”‚ โ””โ”€โ”€ โ†’:7: (shared) -โ”‚ โ””โ”€โ”€ โ–บ:5[1]:B -โ”‚ โ”œโ”€โ”€ ยทacdc49a (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ ยทf0117e0 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ†’:7: (shared) -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ ๐ŸŸฃc08dc6b (โœ“) - โ”œโ”€โ”€ โ†’:2: (main โ†’:1:) - โ””โ”€โ”€ โ†’:4: (A) - -"#]] - ); - - // A is still shown despite it being fully integrated, as it's still enclosed by the - // workspace tip and the fork-point, at least when we provide the previous known location of the target. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 -โ”œโ”€โ”€ โ‰ก:D on 3183e43 -โ”‚ โ”œโ”€โ”€ :D -โ”‚ โ”‚ โ””โ”€โ”€ ยท9895054 (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ :C -โ”‚ โ”‚ โ”œโ”€โ”€ ยทde625cc (๐Ÿ˜๏ธ) -โ”‚ โ”‚ โ”œโ”€โ”€ ยท23419f8 (๐Ÿ˜๏ธ) -โ”‚ โ”‚ โ””โ”€โ”€ ยท5dc4389 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ :shared -โ”‚ โ”œโ”€โ”€ ยทd4f537e (๐Ÿ˜๏ธ|โœ“) -โ”‚ โ”œโ”€โ”€ ยทb448757 (๐Ÿ˜๏ธ|โœ“) -โ”‚ โ””โ”€โ”€ ยทe9a378d (๐Ÿ˜๏ธ|โœ“) -โ”œโ”€โ”€ โ‰ก:A on 3183e43 -โ”‚ โ”œโ”€โ”€ :A -โ”‚ โ”‚ โ””โ”€โ”€ ยท0bad3af (๐Ÿ˜๏ธ|โœ“) -โ”‚ โ””โ”€โ”€ :shared -โ”‚ โ”œโ”€โ”€ ยทd4f537e (๐Ÿ˜๏ธ|โœ“) -โ”‚ โ”œโ”€โ”€ ยทb448757 (๐Ÿ˜๏ธ|โœ“) -โ”‚ โ””โ”€โ”€ ยทe9a378d (๐Ÿ˜๏ธ|โœ“) -โ””โ”€โ”€ โ‰ก:B on 3183e43 - โ”œโ”€โ”€ :B - โ”‚ โ”œโ”€โ”€ ยทacdc49a (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ ยทf0117e0 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :shared - โ”œโ”€โ”€ ยทd4f537e (๐Ÿ˜๏ธ|โœ“) - โ”œโ”€โ”€ ยทb448757 (๐Ÿ˜๏ธ|โœ“) - โ””โ”€โ”€ ยทe9a378d (๐Ÿ˜๏ธ|โœ“) - -"#]] - ); - - // If we do not, integrated portions are removed. - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on d4f537e -โ”œโ”€โ”€ โ‰ก:D on d4f537e -โ”‚ โ”œโ”€โ”€ :D -โ”‚ โ”‚ โ””โ”€โ”€ ยท9895054 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ :C -โ”‚ โ”œโ”€โ”€ ยทde625cc (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ ยท23419f8 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ยท5dc4389 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก:B on d4f537e - โ””โ”€โ”€ :B - โ”œโ”€โ”€ ยทacdc49a (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ยทf0117e0 (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -#[test] -fn multi_lane_with_shared_segment() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/multi-lane-with-shared-segment")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -*-. 2b30d94 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -|\ \ -| | * acdc49a (B) B2 -| | * f0117e0 B1 -| * | 0bad3af (A) A1 -| |/ -* | 9895054 (D) D1 -* | de625cc (C) C3 -* | 23419f8 C2 -* | 5dc4389 C1 -|/ -* d4f537e (shared) S3 -* b448757 S2 -* e9a378d S1 -| * bce0c5e (origin/main) M2 -|/ -* 3183e43 (main) M1 - -"#]] - .raw() - ); - - add_workspace(&mut meta); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท2b30d94 (โŒ‚|๐Ÿ˜|1) -โ”‚ โ”œโ”€โ”€ โ–บ:2[1]:D -โ”‚ โ”‚ โ””โ”€โ”€ ยท9895054 (โŒ‚|๐Ÿ˜|1) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:6[2]:C -โ”‚ โ”‚ โ”œโ”€โ”€ ยทde625cc (โŒ‚|๐Ÿ˜|1) -โ”‚ โ”‚ โ”œโ”€โ”€ ยท23419f8 (โŒ‚|๐Ÿ˜|1) -โ”‚ โ”‚ โ””โ”€โ”€ ยท5dc4389 (โŒ‚|๐Ÿ˜|1) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:7[3]:shared -โ”‚ โ”‚ โ”œโ”€โ”€ ยทd4f537e (โŒ‚|๐Ÿ˜|1) -โ”‚ โ”‚ โ”œโ”€โ”€ ยทb448757 (โŒ‚|๐Ÿ˜|1) -โ”‚ โ”‚ โ””โ”€โ”€ ยทe9a378d (โŒ‚|๐Ÿ˜|1) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:5[4]:main <> origin/main โ†’:1: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“|1) -โ”‚ โ”œโ”€โ”€ โ–บ:3[1]:A -โ”‚ โ”‚ โ””โ”€โ”€ ยท0bad3af (โŒ‚|๐Ÿ˜|1) -โ”‚ โ”‚ โ””โ”€โ”€ โ†’:7: (shared) -โ”‚ โ””โ”€โ”€ โ–บ:4[1]:B -โ”‚ โ”œโ”€โ”€ ยทacdc49a (โŒ‚|๐Ÿ˜|1) -โ”‚ โ””โ”€โ”€ ยทf0117e0 (โŒ‚|๐Ÿ˜|1) -โ”‚ โ””โ”€โ”€ โ†’:7: (shared) -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:5: - โ””โ”€โ”€ ๐ŸŸฃbce0c5e (โœ“) - โ””โ”€โ”€ โ†’:5: (main โ†’:1:) - -"#]] - ); - - // Segments can definitely repeat - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 -โ”œโ”€โ”€ โ‰ก:D on 3183e43 -โ”‚ โ”œโ”€โ”€ :D -โ”‚ โ”‚ โ””โ”€โ”€ ยท9895054 (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ :C -โ”‚ โ”‚ โ”œโ”€โ”€ ยทde625cc (๐Ÿ˜๏ธ) -โ”‚ โ”‚ โ”œโ”€โ”€ ยท23419f8 (๐Ÿ˜๏ธ) -โ”‚ โ”‚ โ””โ”€โ”€ ยท5dc4389 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ :shared -โ”‚ โ”œโ”€โ”€ ยทd4f537e (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ ยทb448757 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ยทe9a378d (๐Ÿ˜๏ธ) -โ”œโ”€โ”€ โ‰ก:A on 3183e43 -โ”‚ โ”œโ”€โ”€ :A -โ”‚ โ”‚ โ””โ”€โ”€ ยท0bad3af (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ :shared -โ”‚ โ”œโ”€โ”€ ยทd4f537e (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ ยทb448757 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ยทe9a378d (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก:B on 3183e43 - โ”œโ”€โ”€ :B - โ”‚ โ”œโ”€โ”€ ยทacdc49a (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ ยทf0117e0 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :shared - โ”œโ”€โ”€ ยทd4f537e (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ยทb448757 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ยทe9a378d (๐Ÿ˜๏ธ) - -"#]] - ); - - let (id, ref_name) = id_at(&repo, "A"); - let graph = Graph::from_commit_traversal( - id, - Some(ref_name), - &*meta, - project_meta(&*meta), - standard_options(), - )? - .validated()?; - // Checking out anything inside the workspace yields the same result. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 3183e43 -โ”œโ”€โ”€ โ‰ก:D on 3183e43 -โ”‚ โ”œโ”€โ”€ :D -โ”‚ โ”‚ โ””โ”€โ”€ ยท9895054 (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ :C -โ”‚ โ”‚ โ”œโ”€โ”€ ยทde625cc (๐Ÿ˜๏ธ) -โ”‚ โ”‚ โ”œโ”€โ”€ ยท23419f8 (๐Ÿ˜๏ธ) -โ”‚ โ”‚ โ””โ”€โ”€ ยท5dc4389 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ :shared -โ”‚ โ”œโ”€โ”€ ยทd4f537e (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ ยทb448757 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ยทe9a378d (๐Ÿ˜๏ธ) -โ”œโ”€โ”€ โ‰ก๐Ÿ‘‰:A on 3183e43 -โ”‚ โ”œโ”€โ”€ ๐Ÿ‘‰:A -โ”‚ โ”‚ โ””โ”€โ”€ ยท0bad3af (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ :shared -โ”‚ โ”œโ”€โ”€ ยทd4f537e (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ ยทb448757 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ยทe9a378d (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก:B on 3183e43 - โ”œโ”€โ”€ :B - โ”‚ โ”œโ”€โ”€ ยทacdc49a (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ ยทf0117e0 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :shared - โ”œโ”€โ”€ ยทd4f537e (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ยทb448757 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ยทe9a378d (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -#[test] -fn local_branch_tracking_the_target_does_not_duplicate_the_target_segment() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/multi-lane-with-shared-segment")?; - add_workspace(&mut meta); - - // `main` tracks the target `origin/main`. Remote-tracking discovery at `main` must - // recognize the project-metadata target ref as already queued instead of inserting - // a second `origin/main` segment, which can leave disconnected segments behind. - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - let target_segments = graph - .segments() - .filter(|sidx| { - graph[*sidx] - .ref_name() - .is_some_and(|rn| rn.as_bstr() == "refs/remotes/origin/main") - }) - .count(); - assert_eq!( - target_segments, 1, - "the initial target tip owns the only segment for the target ref" - ); - Ok(()) -} - -#[test] -fn dependent_branch_insertion() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario( - "ws/two-branches-one-advanced-two-parent-ws-commit-advanced-fully-pushed-empty-dependent", - )?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 335d6f2 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -|\ -| * cbc6713 (origin/advanced-lane, dependent, advanced-lane) change -|/ -* fafd9d0 (origin/main, main, lane) init - -"#]] - .raw() - ); - - add_stack_with_segments( - &mut meta, - 1, - "dependent", - StackState::InWorkspace, - &["advanced-lane"], - ); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท335d6f2 (โŒ‚|๐Ÿ˜|001) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:5[1]:dependent -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:6[2]:advanced-lane <> origin/advanced-lane โ†’:4: -โ”‚ โ”‚ โ””โ”€โ”€ ยทcbc6713 (โŒ‚|๐Ÿ˜|101) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:2[3]:main <> origin/main โ†’:1: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|111) โ–บlane -โ”‚ โ””โ”€โ”€ โ†’:2: (main โ†’:1:) -โ”œโ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ โ†’:2: (main โ†’:1:) -โ””โ”€โ”€ โ–บ:4[0]:origin/advanced-lane โ†’:6: - โ””โ”€โ”€ โ†’:6: (advanced-lane โ†’:4:) - -"#]] - ); - - // The dependent branch is empty and on top of the one with the remote - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:dependent on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:dependent - โ””โ”€โ”€ ๐Ÿ“™:advanced-lane <> origin/advanced-lane - โ””โ”€โ”€ โ„๏ธcbc6713 (๐Ÿ˜๏ธ) - -"#]] - ); - - // Create the dependent branch below. - add_stack_with_segments( - &mut meta, - 1, - "advanced-lane", - StackState::InWorkspace, - &["dependent"], - ); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท335d6f2 (โŒ‚|๐Ÿ˜|001) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:5[1]:advanced-lane <> origin/advanced-lane โ†’:4: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:6[2]:dependent -โ”‚ โ”‚ โ””โ”€โ”€ ยทcbc6713 (โŒ‚|๐Ÿ˜|101) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:2[3]:main <> origin/main โ†’:1: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|111) โ–บlane -โ”‚ โ””โ”€โ”€ โ†’:2: (main โ†’:1:) -โ”œโ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ โ†’:2: (main โ†’:1:) -โ””โ”€โ”€ โ–บ:4[0]:origin/advanced-lane โ†’:5: - โ””โ”€โ”€ โ†’:5: (advanced-lane โ†’:4:) - -"#]] - ); - - // Having done something unusual, which is to put the dependent branch - // underneath the other already pushed, it creates a different view of ownership. - // It's probably OK to leave it like this for now, and instead allow users to reorder - // these more easily. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:advanced-lane <> origin/advanced-lane on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:advanced-lane <> origin/advanced-lane - โ””โ”€โ”€ ๐Ÿ“™:dependent - โ””โ”€โ”€ โ„cbc6713 (๐Ÿ˜๏ธ) - -"#]] - ); - - let (id, ref_name) = id_at(&repo, "advanced-lane"); - let graph = Graph::from_commit_traversal( - id, - ref_name, - &*meta, - project_meta(&*meta), - standard_options(), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:advanced-lane <> origin/advanced-lane on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:advanced-lane <> origin/advanced-lane - โ””โ”€โ”€ ๐Ÿ“™:dependent - โ””โ”€โ”€ โ„cbc6713 (๐Ÿ˜๏ธ) - -"#]] - ); - - let (id, ref_name) = id_at(&repo, "dependent"); - let graph = Graph::from_commit_traversal( - id, - ref_name, - &*meta, - project_meta(&*meta), - standard_options(), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:advanced-lane <> origin/advanced-lane on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:advanced-lane <> origin/advanced-lane - โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:dependent - โ””โ”€โ”€ โ„cbc6713 (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -#[test] -fn multiple_stacks_with_shared_parent_and_remote() -> anyhow::Result<()> { - let (repo, mut meta) = - read_only_in_memory_scenario("ws/multiple-stacks-with-shared-segment-and-remote")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* e982e8a (HEAD -> gitbutler/workspace) GitButler Workspace Commit -|\ -| * aff8449 (B-on-A) B-on-A -* | 4f1bb32 (C-on-A) C-on-A -|/ -| * b627ca7 (origin/A) A-on-remote -|/ -* e255adc (A) A -* fafd9d0 (origin/main, main) init - -"#]] - .raw() - ); - - add_stack_with_segments(&mut meta, 1, "C-on-A", StackState::InWorkspace, &[]); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยทe982e8a (โŒ‚|๐Ÿ˜|0001) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:3[1]:C-on-A -โ”‚ โ”‚ โ””โ”€โ”€ ยท4f1bb32 (โŒ‚|๐Ÿ˜|0001) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:4[2]:A <> origin/A โ†’:5: -โ”‚ โ”‚ โ””โ”€โ”€ ยทe255adc (โŒ‚|๐Ÿ˜|1101) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:2[3]:main <> origin/main โ†’:1: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|1111) -โ”‚ โ””โ”€โ”€ โ–บ:6[1]:B-on-A -โ”‚ โ””โ”€โ”€ ยทaff8449 (โŒ‚|๐Ÿ˜|0001) -โ”‚ โ””โ”€โ”€ โ†’:4: (A โ†’:5:) -โ”œโ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ โ†’:2: (main โ†’:1:) -โ””โ”€โ”€ โ–บ:5[0]:origin/A โ†’:4: - โ””โ”€โ”€ ๐ŸŸฃb627ca7 (0x0|1000) - โ””โ”€โ”€ โ†’:4: (A โ†’:5:) - -"#]] - ); - - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C-on-A on fafd9d0 {1} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C-on-A -โ”‚ โ”‚ โ””โ”€โ”€ ยท4f1bb32 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ :A <> origin/Aโ‡ฃ1 -โ”‚ โ”œโ”€โ”€ ๐ŸŸฃb627ca7 -โ”‚ โ””โ”€โ”€ โ„๏ธe255adc (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก:B-on-A on fafd9d0 - โ”œโ”€โ”€ :B-on-A - โ”‚ โ””โ”€โ”€ ยทaff8449 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :A <> origin/Aโ‡ฃ1 - โ”œโ”€โ”€ ๐ŸŸฃb627ca7 - โ””โ”€โ”€ โ„๏ธe255adc (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -#[test] -fn a_stack_segment_can_be_a_segment_elsewhere_and_stack_order() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario( - "ws/two-branches-one-advanced-two-parent-ws-commit-diverged-ttb", - )?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 873d056 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -|\ -* | cbc6713 (advanced-lane) change -|/ -* fafd9d0 (main, lane) init -* da83717 (origin/main) disjoint remote target - -"#]] - .raw() - ); - - let lanes = ["advanced-lane", "lane"]; - for (idx, name) in lanes.into_iter().enumerate() { - add_stack_with_segments(&mut meta, idx, name, StackState::InWorkspace, &[]); - } - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท873d056 (โŒ‚|๐Ÿ˜|1) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:2[1]:advanced-lane -โ”‚ โ”‚ โ””โ”€โ”€ ยทcbc6713 (โŒ‚|๐Ÿ˜|1) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:3[2]:anon: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|1) โ–บmain -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:4[1]:lane -โ”‚ โ””โ”€โ”€ โ†’:3: -โ””โ”€โ”€ โ–บ:1[0]:origin/main - โ””โ”€โ”€ ๐Ÿ๐ŸŸฃda83717 (โœ“) - -"#]] - ); - - // Since `lane` is connected directly, no segment has to be created. - // However, as nothing is integrated, it really is another name for `main` now, - // `main` is nothing special. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:advanced-lane on fafd9d0 {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:advanced-lane -โ”‚ โ””โ”€โ”€ ยทcbc6713 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:lane on fafd9d0 {1} - โ””โ”€โ”€ ๐Ÿ“™:lane - -"#]] - ); - - // Reverse the order of stacks in the worktree data. - for (idx, name) in lanes.into_iter().rev().enumerate() { - add_stack_with_segments(&mut meta, idx, name, StackState::InWorkspace, &[]); - } - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท873d056 (โŒ‚|๐Ÿ˜|1) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:4[1]:lane -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:2[2]:anon: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|1) โ–บmain -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:3[1]:advanced-lane -โ”‚ โ””โ”€โ”€ ยทcbc6713 (โŒ‚|๐Ÿ˜|1) -โ”‚ โ””โ”€โ”€ โ†’:2: -โ””โ”€โ”€ โ–บ:1[0]:origin/main - โ””โ”€โ”€ ๐Ÿ๐ŸŸฃda83717 (โœ“) - -"#]] - ); - - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:lane on fafd9d0 {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:lane -โ””โ”€โ”€ โ‰ก๐Ÿ“™:advanced-lane on fafd9d0 {1} - โ””โ”€โ”€ ๐Ÿ“™:advanced-lane - โ””โ”€โ”€ ยทcbc6713 (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -#[test] -fn two_dependent_branches_with_embedded_remote() -> anyhow::Result<()> { - let (repo, mut meta) = - read_only_in_memory_scenario("ws/two-dependent-branches-with-interesting-remote-setup")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* a221221 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -* aadad9d (A) shared by name -* 96a2408 (origin/main) another unrelated -| * 2b1808c (origin/A) shared by name -|/ -* f15ca75 (integrated) other integrated -* 9456d79 integrated in target -* fafd9d0 (main) init - -"#]] - ); - - // Just a single explicit reference we want to know of. - add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &[]); - - // Note how the target remote tracking branch is integrated into the stack - let graph = Graph::from_head( - &repo, - &*meta, - project_meta(&*meta), - standard_options_with_extra_target(&repo, "main"), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยทa221221 (โŒ‚|๐Ÿ˜|0001) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:3[1]:A <> origin/A โ†’:4: -โ”‚ โ””โ”€โ”€ ยทaadad9d (โŒ‚|๐Ÿ˜|0101) -โ”‚ โ””โ”€โ”€ โ–บ:1[2]:origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ ยท96a2408 (โŒ‚|๐Ÿ˜|โœ“|0101) -โ”‚ โ””โ”€โ”€ โ–บ:5[3]:integrated -โ”‚ โ”œโ”€โ”€ ยทf15ca75 (โŒ‚|๐Ÿ˜|โœ“|1101) -โ”‚ โ””โ”€โ”€ ยท9456d79 (โŒ‚|๐Ÿ˜|โœ“|1101) -โ”‚ โ””โ”€โ”€ โ–บ:2[4]:main <> origin/main โ†’:1: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|1111) -โ””โ”€โ”€ โ–บ:4[0]:origin/A โ†’:3: - โ””โ”€โ”€ ๐ŸŸฃ2b1808c (0x0|1000) - โ””โ”€โ”€ โ†’:5: (integrated) - -"#]] - ); - - // Remote tracking branches we just want to aggregate, just like anonymous segments, - // but only when another target is provided (the old position, `main`). - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:A <> origin/Aโ‡ก1โ‡ฃ1 on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:A <> origin/Aโ‡ก1โ‡ฃ1 - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ2b1808c - โ”‚ โ”œโ”€โ”€ ยทaadad9d (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ ยท96a2408 (๐Ÿ˜๏ธ|โœ“) - โ””โ”€โ”€ :integrated - โ”œโ”€โ”€ โ„f15ca75 (๐Ÿ˜๏ธ|โœ“) - โ””โ”€โ”€ โ„9456d79 (๐Ÿ˜๏ธ|โœ“) - -"#]] - ); - - // Otherwise, nothing that's integrated is shown. Note how 96a2408 seems missing, - // but it's skipped because it's actually part of an integrated otherwise ignored segment. - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 96a2408 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:A <> origin/Aโ‡ก1โ‡ฃ1 on 96a2408 {1} - โ””โ”€โ”€ ๐Ÿ“™:A <> origin/Aโ‡ก1โ‡ฃ1 - โ”œโ”€โ”€ ๐ŸŸฃ2b1808c - โ””โ”€โ”€ ยทaadad9d (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -#[test] -fn two_dependent_branches_rebased_with_remotes_merge_local() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario( - "ws/two-dependent-branches-rebased-with-remotes-merge-one-local", - )?; - // Each of the stacked branches has a remote, and the local branch was merged into main. - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* e0bd0a7 (origin/B) B -* 0b6b861 (origin/A) A -| * b694668 (origin/main) Merge branch 'A' into soon-origin-main -|/| -| | * 4f08b8d (HEAD -> gitbutler/workspace) GitButler Workspace Commit -| | * da597e8 (B) B -| |/ -| * 1818c17 (A) A -|/ -* 281456a (main) init - -"#]] - ); - - add_stack_with_segments(&mut meta, 0, "B", StackState::InWorkspace, &["A"]); - - let graph = Graph::from_head( - &repo, - &*meta, - project_meta(&*meta), - standard_options_with_extra_target(&repo, "main"), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท4f08b8d (โŒ‚|๐Ÿ˜|000001) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:3[1]:B <> origin/B โ†’:5: -โ”‚ โ””โ”€โ”€ ยทda597e8 (โŒ‚|๐Ÿ˜|000101) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:4[2]:A <> origin/A โ†’:6: -โ”‚ โ””โ”€โ”€ ยท1818c17 (โŒ‚|๐Ÿ˜|โœ“|010101) -โ”‚ โ””โ”€โ”€ โ–บ:2[3]:main <> origin/main โ†’:1: -โ”‚ โ””โ”€โ”€ ๐Ÿยท281456a (โŒ‚|๐Ÿ˜|โœ“|111111) -โ”œโ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ ๐ŸŸฃb694668 (โœ“) -โ”‚ โ”œโ”€โ”€ โ†’:2: (main โ†’:1:) -โ”‚ โ””โ”€โ”€ โ†’:4: (A โ†’:6:) -โ””โ”€โ”€ โ–บ:5[0]:origin/B โ†’:3: - โ””โ”€โ”€ ๐ŸŸฃe0bd0a7 (0x0|001000) - โ””โ”€โ”€ โ–บ:6[1]:origin/A โ†’:4: - โ””โ”€โ”€ ๐ŸŸฃ0b6b861 (0x0|101000) - โ””โ”€โ”€ โ†’:2: (main โ†’:1:) - -"#]] - ); - - // This is the default as it includes both the integrated and non-integrated segment. - // Note how there is no expensive computation to see if remote commits are the same, - // it's all ID-based. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 281456a -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B <> origin/Bโ‡ก1โ‡ฃ1 on 281456a {0} - โ”œโ”€โ”€ ๐Ÿ“™:B <> origin/Bโ‡ก1โ‡ฃ1 - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃe0bd0a7 - โ”‚ โ””โ”€โ”€ ยทda597e8 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:A <> origin/Aโ‡ฃ1 - โ”œโ”€โ”€ ๐ŸŸฃ0b6b861 - โ””โ”€โ”€ ยท1818c17 (๐Ÿ˜๏ธ|โœ“) - -"#]] - ); - - let graph = Graph::from_head( - &repo, - &*meta, - project_meta(&*meta), - standard_options_with_extra_target(&repo, "A"), - )? - .validated()?; - // Pretending we are rebased onto A still shows the same remote commits. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 1818c17 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B <> origin/Bโ‡ก1โ‡ฃ1 on 1818c17 {0} - โ””โ”€โ”€ ๐Ÿ“™:B <> origin/Bโ‡ก1โ‡ฃ1 - โ”œโ”€โ”€ ๐ŸŸฃe0bd0a7 - โ””โ”€โ”€ ยทda597e8 (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -#[test] -fn stacked_bottom_remote_still_points_at_now_split_top() -> anyhow::Result<()> { - let (repo, mut meta) = - read_only_in_memory_scenario("ws/stacked-bottom-remote-still-points-at-now-split-top")?; - // origin/bottom still points at T (the previously combined push), but the - // local stack is now split so that bottom holds only B and top holds T on - // top of bottom. To remove T from origin/bottom we'd need to force-push, - // so bottom must report `commits_on_remote` containing T. - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 5c66c47 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -* bfbff44 (origin/bottom, top) T -* 7fdb58d (bottom) B -* fafd9d0 (origin/main, main) init - -"#]] - ); - - add_stack_with_segments(&mut meta, 0, "top", StackState::InWorkspace, &["bottom"]); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:top on fafd9d0 {0} - โ”œโ”€โ”€ ๐Ÿ“™:top - โ”‚ โ””โ”€โ”€ โ„bfbff44 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:bottom <> origin/bottomโ‡ฃ1 - โ”œโ”€โ”€ ๐ŸŸฃbfbff44 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ โ„๏ธ7fdb58d (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -#[test] -fn two_dependent_branches_rebased_with_remotes_squash_merge_remote_ambiguous() -> anyhow::Result<()> -{ - let (repo, mut meta) = read_only_in_memory_scenario( - "ws/two-dependent-branches-rebased-with-remotes-squash-merge-one-remote-ambiguous", - )?; - // Each of the stacked branches has a remote, the remote branch was merged into main, - // and the remaining branch B was rebased onto the merge, simulating a workspace update. - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 1109eb2 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -* 624e118 (D) D -* 0b6b861 (origin/main, main) A -| * 3045ea6 (origin/D) D -| * 1818c17 (origin/C, origin/B, origin/A) A -|/ -* 281456a init - -"#]] - ); - - // The branch A, B, C are not in the workspace anymore, and we *could* signal it by removing metadata. - // But even with metadata, it still works fine. - add_stack_with_segments(&mut meta, 0, "D", StackState::InWorkspace, &["C", "B", "A"]); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท1109eb2 (โŒ‚|๐Ÿ˜|0001) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:3[1]:D <> origin/D โ†’:4: -โ”‚ โ””โ”€โ”€ ยท624e118 (โŒ‚|๐Ÿ˜|0101) -โ”‚ โ””โ”€โ”€ โ–บ:2[2]:main <> origin/main โ†’:1: -โ”‚ โ””โ”€โ”€ ยท0b6b861 (โŒ‚|๐Ÿ˜|โœ“|0111) -โ”‚ โ””โ”€โ”€ โ–บ:5[3]:anon: -โ”‚ โ””โ”€โ”€ ๐Ÿยท281456a (โŒ‚|๐Ÿ˜|โœ“|1111) -โ”œโ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ โ†’:2: (main โ†’:1:) -โ”œโ”€โ”€ โ–บ:4[0]:origin/D โ†’:3: -โ”‚ โ””โ”€โ”€ ๐ŸŸฃ3045ea6 (0x0|1000) -โ”‚ โ””โ”€โ”€ โ–บ:6[1]:origin/A -โ”‚ โ””โ”€โ”€ ๐ŸŸฃ1818c17 (0x0|1000) -โ”‚ โ””โ”€โ”€ โ†’:5: -โ”œโ”€โ”€ โ–บ:7[0]:origin/B -โ”‚ โ””โ”€โ”€ โ†’:6: (origin/A) -โ””โ”€โ”€ โ–บ:8[0]:origin/C - โ””โ”€โ”€ โ†’:6: (origin/A) - -"#]] - ); - - let ambiguous_remote_tip = repo.rev_parse_single("origin/A")?.detach(); - for remote_ref in [ - "refs/remotes/origin/A", - "refs/remotes/origin/B", - "refs/remotes/origin/C", - ] { - let remote_ref = super::ref_name(remote_ref); - let remote_segment = graph - .segment_by_ref_name(remote_ref.as_ref()) - .expect("remote tracking segment should be present"); - assert_eq!( - graph.tip_skip_empty(remote_segment.id).map(|c| c.id), - Some(ambiguous_remote_tip), - "{remote_ref} should resolve to the commit its Git ref points to, showing that something special happened here" - ); - } - - // only one remote commit as unrelated remotes split a linear segment - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 0b6b861 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:D <> origin/Dโ‡ก1โ‡ฃ1 on 0b6b861 {0} - โ””โ”€โ”€ ๐Ÿ“™:D <> origin/Dโ‡ก1โ‡ฃ1 - โ”œโ”€โ”€ ๐ŸŸฃ3045ea6 - โ””โ”€โ”€ ยท624e118 (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -#[test] -fn two_dependent_branches_rebased_with_remotes_squash_merge_remote() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario( - "ws/two-dependent-branches-rebased-with-remotes-squash-merge-one-remote", - )?; - // Each of the stacked branches has a remote, the remote branch was merged into main, - // and the remaining branch B was rebased onto the merge, simulating a workspace update. - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* deeae50 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -* 353471f (D) D -* 8a4b945 C -* e0bd0a7 B -* 0b6b861 (origin/main, main) A -| * bbd4ff6 (origin/D) D -| * e5f5a87 (origin/C) C -| * da597e8 (origin/B) B -| * 1818c17 (origin/A) A -|/ -* 281456a init - -"#]] - ); - - // The branch A, B, C are not in the workspace anymore, and we *could* signal it by removing metadata. - // But even with metadata, it still works fine. - add_stack_with_segments(&mut meta, 0, "D", StackState::InWorkspace, &["C", "B", "A"]); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยทdeeae50 (โŒ‚|๐Ÿ˜|0001) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:3[1]:D <> origin/D โ†’:4: -โ”‚ โ”œโ”€โ”€ ยท353471f (โŒ‚|๐Ÿ˜|0101) -โ”‚ โ”œโ”€โ”€ ยท8a4b945 (โŒ‚|๐Ÿ˜|0101) -โ”‚ โ””โ”€โ”€ ยทe0bd0a7 (โŒ‚|๐Ÿ˜|0101) -โ”‚ โ””โ”€โ”€ โ–บ:2[2]:main <> origin/main โ†’:1: -โ”‚ โ””โ”€โ”€ ยท0b6b861 (โŒ‚|๐Ÿ˜|โœ“|0111) -โ”‚ โ””โ”€โ”€ โ–บ:5[4]:anon: -โ”‚ โ””โ”€โ”€ ๐Ÿยท281456a (โŒ‚|๐Ÿ˜|โœ“|1111) -โ”œโ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ โ†’:2: (main โ†’:1:) -โ””โ”€โ”€ โ–บ:4[0]:origin/D โ†’:3: - โ””โ”€โ”€ ๐ŸŸฃbbd4ff6 (0x0|1000) - โ””โ”€โ”€ โ–บ:8[1]:origin/C - โ””โ”€โ”€ ๐ŸŸฃe5f5a87 (0x0|1000) - โ””โ”€โ”€ โ–บ:7[2]:origin/B - โ””โ”€โ”€ ๐ŸŸฃda597e8 (0x0|1000) - โ””โ”€โ”€ โ–บ:6[3]:origin/A - โ””โ”€โ”€ ๐ŸŸฃ1818c17 (0x0|1000) - โ””โ”€โ”€ โ†’:5: - -"#]] - ); - - // We let each remote on the path down own a commit so we only see one remote commit here, - // the one belonging to the last remaining associated remote tracking branch of D. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 0b6b861 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:D <> origin/Dโ‡ก3โ‡ฃ1 on 0b6b861 {0} - โ””โ”€โ”€ ๐Ÿ“™:D <> origin/Dโ‡ก3โ‡ฃ1 - โ”œโ”€โ”€ ๐ŸŸฃbbd4ff6 - โ”œโ”€โ”€ ยท353471f (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ยท8a4b945 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ยทe0bd0a7 (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -#[test] -fn without_target_ref_or_managed_commit() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/no-target-without-ws-commit")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 4fe5a6f (origin/A) A-remote -* a62b0de (HEAD -> gitbutler/workspace, A) A2 -* 120a217 A1 -* fafd9d0 (main) init - -"#]] - ); - - add_workspace(&mut meta); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ โ–บ:1[1]:A <> origin/A โ†’:2: -โ”‚ โ”œโ”€โ”€ ยทa62b0de (โŒ‚|๐Ÿ˜|11) -โ”‚ โ””โ”€โ”€ ยท120a217 (โŒ‚|๐Ÿ˜|11) -โ”‚ โ””โ”€โ”€ โ–บ:3[2]:main -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|11) -โ””โ”€โ”€ โ–บ:2[0]:origin/A โ†’:1: - โ””โ”€โ”€ ๐ŸŸฃ4fe5a6f (0x0|10) - โ””โ”€โ”€ โ†’:1: (A โ†’:2:) - -"#]] - ); - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:A <> origin/Aโ‡ฃ1 - โ”œโ”€โ”€ :A <> origin/Aโ‡ฃ1 - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ4fe5a6f - โ”‚ โ”œโ”€โ”€ โ„๏ธa62b0de (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ โ„๏ธ120a217 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :main - โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) - -"#]] - ); - - let (id, ref_name) = id_at(&repo, "A"); - let graph = Graph::from_commit_traversal( - id, - ref_name, - &*meta, - project_meta(&*meta), - standard_options(), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[1]:A <> origin/A โ†’:2: -โ”‚ โ”œโ”€โ”€ ยทa62b0de (โŒ‚|๐Ÿ˜|11) -โ”‚ โ””โ”€โ”€ ยท120a217 (โŒ‚|๐Ÿ˜|11) -โ”‚ โ””โ”€โ”€ โ–บ:3[2]:main -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|11) -โ””โ”€โ”€ โ–บ:2[0]:origin/A โ†’:0: - โ””โ”€โ”€ ๐ŸŸฃ4fe5a6f (0x0|10) - โ””โ”€โ”€ โ†’:0: (A โ†’:2:) - -"#]] - ); - - // Main can be a normal segment if there is no target ref. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:A <> origin/Aโ‡ฃ1 - โ”œโ”€โ”€ ๐Ÿ‘‰:A <> origin/Aโ‡ฃ1 - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ4fe5a6f - โ”‚ โ”œโ”€โ”€ โ„๏ธa62b0de (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ โ„๏ธ120a217 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :main - โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -#[test] -fn without_target_ref_or_managed_commit_ambiguous() -> anyhow::Result<()> { - let (repo, mut meta) = - read_only_in_memory_scenario("ws/no-target-without-ws-commit-ambiguous")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 4fe5a6f (origin/A) A-remote -* a62b0de (HEAD -> gitbutler/workspace, B, A) A2 -* 120a217 A1 -* fafd9d0 (main) init - -"#]] - ); - - add_workspace(&mut meta); - // Without disambiguation, there is no segment name. - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ โ–บ:1[1]:A <> origin/A โ†’:2: -โ”‚ โ”œโ”€โ”€ ยทa62b0de (โŒ‚|๐Ÿ˜|11) โ–บB -โ”‚ โ””โ”€โ”€ ยท120a217 (โŒ‚|๐Ÿ˜|11) -โ”‚ โ””โ”€โ”€ โ–บ:3[2]:main -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|11) -โ””โ”€โ”€ โ–บ:2[0]:origin/A โ†’:1: - โ””โ”€โ”€ ๐ŸŸฃ4fe5a6f (0x0|10) - โ””โ”€โ”€ โ†’:1: (A โ†’:2:) - -"#]] - ); - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:A <> origin/Aโ‡ฃ1 - โ”œโ”€โ”€ :A <> origin/Aโ‡ฃ1 - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ4fe5a6f - โ”‚ โ”œโ”€โ”€ โ„๏ธa62b0de (๐Ÿ˜๏ธ) โ–บB - โ”‚ โ””โ”€โ”€ โ„๏ธ120a217 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :main - โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) - -"#]] - ); - - // We can help it by adding metadata. - // Note how the selection still manages to hold on to the `A` which now gets its very own - // empty segment. - add_stack_with_segments(&mut meta, 1, "B", StackState::InWorkspace, &[]); - let (id, a_ref) = id_at(&repo, "A"); - let graph = Graph::from_commit_traversal( - id, - a_ref.clone(), - &*meta, - project_meta(&*meta), - standard_options(), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:4[1]:A <> origin/A โ†’:2: -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:0[2]:B -โ”‚ โ”œโ”€โ”€ ยทa62b0de (โŒ‚|๐Ÿ˜|11) -โ”‚ โ””โ”€โ”€ ยท120a217 (โŒ‚|๐Ÿ˜|11) -โ”‚ โ””โ”€โ”€ โ–บ:3[3]:main -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|11) -โ””โ”€โ”€ โ–บ:2[0]:origin/A โ†’:4: - โ””โ”€โ”€ ๐ŸŸฃ4fe5a6f (0x0|10) - โ””โ”€โ”€ โ†’:4: (A โ†’:2:) - -"#]] - ); - - // Main can be a normal segment if there is no target ref. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:A <> origin/Aโ‡ฃ1 {1} - โ”œโ”€โ”€ ๐Ÿ‘‰:A <> origin/Aโ‡ฃ1 - โ”‚ โ””โ”€โ”€ ๐ŸŸฃ4fe5a6f - โ”œโ”€โ”€ ๐Ÿ“™:B - โ”‚ โ”œโ”€โ”€ โ„a62b0de (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ โ„120a217 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :main - โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) - -"#]] - ); - - // Finally, show the normal version with just disambiguated 'B". - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:1[1]:B -โ”‚ โ”œโ”€โ”€ ยทa62b0de (โŒ‚|๐Ÿ˜|11) โ–บA -โ”‚ โ””โ”€โ”€ ยท120a217 (โŒ‚|๐Ÿ˜|11) -โ”‚ โ””โ”€โ”€ โ–บ:3[2]:main -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|11) -โ””โ”€โ”€ โ–บ:2[0]:origin/A - โ””โ”€โ”€ ๐ŸŸฃ4fe5a6f (0x0|10) - โ””โ”€โ”€ โ†’:1: (B) - -"#]] - ); - - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B {1} - โ”œโ”€โ”€ ๐Ÿ“™:B - โ”‚ โ”œโ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ) โ–บA - โ”‚ โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :main - โ””โ”€โ”€ ยทfafd9d0 (๐Ÿ˜๏ธ) - -"#]] - ); - - // Order is respected - add_stack_with_segments(&mut meta, 1, "B", StackState::InWorkspace, &["A"]); - let graph = Graph::from_commit_traversal( - id, - a_ref.clone(), - &*meta, - project_meta(&*meta), - standard_options(), - )? - .validated()?; - // The remote tracking branch must remain linked. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B {1} - โ”œโ”€โ”€ ๐Ÿ“™:B - โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:A <> origin/Aโ‡ฃ1 - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ4fe5a6f - โ”‚ โ”œโ”€โ”€ โ„๏ธa62b0de (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ โ„๏ธ120a217 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :main - โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) - -"#]] - ); - - // Order is respected, vice-versa - add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &["B"]); - let graph = - Graph::from_commit_traversal(id, a_ref, &*meta, project_meta(&*meta), standard_options())? - .validated()?; - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:A <> origin/Aโ‡ฃ1 {1} - โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:A <> origin/Aโ‡ฃ1 - โ”‚ โ””โ”€โ”€ ๐ŸŸฃ4fe5a6f - โ”œโ”€โ”€ ๐Ÿ“™:B - โ”‚ โ”œโ”€โ”€ โ„a62b0de (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ โ„120a217 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :main - โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) - -"#]] - ); - - Ok(()) -} - -#[test] -fn without_target_ref_or_managed_commit_ambiguous_with_remotes() -> anyhow::Result<()> { - let (repo, mut meta) = - read_only_in_memory_scenario("ws/no-target-without-ws-commit-ambiguous-with-remotes")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* a62b0de (HEAD -> gitbutler/workspace, origin/B, origin/A, B, A) A2 -* 120a217 A1 -* fafd9d0 (main) init - -"#]] - ); - - add_workspace(&mut meta); - // Without disambiguation, there is no segment name. - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ โ–บ:1[1]:anon: -โ”‚ โ”œโ”€โ”€ ยทa62b0de (โŒ‚|๐Ÿ˜|1) โ–บA, โ–บB -โ”‚ โ””โ”€โ”€ ยท120a217 (โŒ‚|๐Ÿ˜|1) -โ”‚ โ””โ”€โ”€ โ–บ:4[2]:main <> origin/main -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|1) -โ”œโ”€โ”€ โ–บ:2[0]:origin/A -โ”‚ โ””โ”€โ”€ โ†’:1: -โ””โ”€โ”€ โ–บ:3[0]:origin/B - โ””โ”€โ”€ โ†’:1: - -"#]] - ); - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:anon: - โ”œโ”€โ”€ :anon: - โ”‚ โ”œโ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ) โ–บA, โ–บB - โ”‚ โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :main <> origin/mainโ‡ก1 - โ””โ”€โ”€ ยทfafd9d0 (๐Ÿ˜๏ธ) - -"#]] - ); - - // Remote handling is still happening when A is disambiguated by entrypoint. - let (id, a_ref) = id_at(&repo, "A"); - let graph = Graph::from_commit_traversal( - id, - a_ref.clone(), - &*meta, - project_meta(&*meta), - standard_options(), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[1]:A <> origin/A โ†’:2: -โ”‚ โ”œโ”€โ”€ ยทa62b0de (โŒ‚|๐Ÿ˜|1) โ–บB -โ”‚ โ””โ”€โ”€ ยท120a217 (โŒ‚|๐Ÿ˜|1) -โ”‚ โ””โ”€โ”€ โ–บ:4[2]:main <> origin/main -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|1) -โ”œโ”€โ”€ โ–บ:2[0]:origin/A โ†’:0: -โ”‚ โ””โ”€โ”€ โ†’:0: (A โ†’:2:) -โ””โ”€โ”€ โ–บ:3[0]:origin/B - โ””โ”€โ”€ โ†’:0: (A โ†’:2:) - -"#]] - ); - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:A <> origin/A - โ”œโ”€โ”€ ๐Ÿ‘‰:A <> origin/A - โ”‚ โ”œโ”€โ”€ โ„๏ธa62b0de (๐Ÿ˜๏ธ) โ–บB - โ”‚ โ””โ”€โ”€ โ„๏ธ120a217 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :main <> origin/main - โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) - -"#]] - ); - - // The same is true when starting at a different ref. - let (id, b_ref) = id_at(&repo, "B"); - let graph = - Graph::from_commit_traversal(id, b_ref, &*meta, project_meta(&*meta), standard_options())? - .validated()?; - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:B <> origin/B - โ”œโ”€โ”€ ๐Ÿ‘‰:B <> origin/B - โ”‚ โ”œโ”€โ”€ โ„๏ธa62b0de (๐Ÿ˜๏ธ) โ–บA - โ”‚ โ””โ”€โ”€ โ„๏ธ120a217 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :main <> origin/main - โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) - -"#]] - ); - - // If disambiguation happens through the workspace, 'A' still shows the right remote, and 'B' as well - add_stack_with_segments(&mut meta, 1, "B", StackState::InWorkspace, &[]); - let graph = Graph::from_commit_traversal( - id, - a_ref.clone(), - &*meta, - project_meta(&*meta), - standard_options(), - )? - .validated()?; - // NOTE: origin/A points to :5, but origin/B now also points to :5 even though it should point to :0, - // a relationship still preserved though the sibling ID. - // There is no easy way of fixing this as we'd have to know that this one connection, which can - // indirectly reach the remote tracking segment, should remain on the local tracking segment when - // reconnecting them during the segment insertion. - // This is acceptable as graph connections aren't used for this, and ultimately they still - // reach the right segment, just through one more indirection. Empty segments are 'looked through' - // as well by all algorithms for exactly that reason. - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:5[1]:A <> origin/A โ†’:2: -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:0[2]:B <> origin/B โ†’:3: -โ”‚ โ”œโ”€โ”€ ยทa62b0de (โŒ‚|๐Ÿ˜|1) -โ”‚ โ””โ”€โ”€ ยท120a217 (โŒ‚|๐Ÿ˜|1) -โ”‚ โ””โ”€โ”€ โ–บ:4[3]:main <> origin/main -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|1) -โ”œโ”€โ”€ โ–บ:2[0]:origin/A โ†’:5: -โ”‚ โ””โ”€โ”€ โ†’:5: (A โ†’:2:) -โ””โ”€โ”€ โ–บ:3[0]:origin/B โ†’:0: - โ””โ”€โ”€ โ†’:0: (B โ†’:3:) - -"#]] - ); - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:A <> origin/A {1} - โ”œโ”€โ”€ ๐Ÿ‘‰:A <> origin/A - โ”œโ”€โ”€ ๐Ÿ“™:B <> origin/B - โ”‚ โ”œโ”€โ”€ โ„๏ธa62b0de (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ โ„๏ธ120a217 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :main <> origin/main - โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -#[test] -fn without_target_ref_with_managed_commit() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/no-target-with-ws-commit")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 3ea2742 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -| * 4fe5a6f (origin/A) A-remote -|/ -* a62b0de (A) A2 -* 120a217 A1 -* fafd9d0 (main) init - -"#]] - ); - - add_workspace(&mut meta); - // The commit is ambiguous, so there is just the entrypoint to split the segment. - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท3ea2742 (โŒ‚|๐Ÿ˜|001) -โ”‚ โ””โ”€โ”€ โ–บ:1[1]:A <> origin/A โ†’:2: -โ”‚ โ”œโ”€โ”€ ยทa62b0de (โŒ‚|๐Ÿ˜|111) -โ”‚ โ””โ”€โ”€ ยท120a217 (โŒ‚|๐Ÿ˜|111) -โ”‚ โ””โ”€โ”€ โ–บ:3[2]:main -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|111) -โ””โ”€โ”€ โ–บ:2[0]:origin/A โ†’:1: - โ””โ”€โ”€ ๐ŸŸฃ4fe5a6f (0x0|100) - โ””โ”€โ”€ โ†’:1: (A โ†’:2:) - -"#]] - ); - // TODO: add more stacks. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:A <> origin/Aโ‡ฃ1 - โ”œโ”€โ”€ :A <> origin/Aโ‡ฃ1 - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ4fe5a6f - โ”‚ โ”œโ”€โ”€ โ„๏ธa62b0de (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ โ„๏ธ120a217 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :main - โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) - -"#]] - ); - - let (id, ref_name) = id_at(&repo, "A"); - let graph = Graph::from_commit_traversal( - id, - ref_name, - &*meta, - project_meta(&*meta), - standard_options(), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท3ea2742 (โŒ‚|๐Ÿ˜) -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[1]:A <> origin/A โ†’:2: -โ”‚ โ”œโ”€โ”€ ยทa62b0de (โŒ‚|๐Ÿ˜|11) -โ”‚ โ””โ”€โ”€ ยท120a217 (โŒ‚|๐Ÿ˜|11) -โ”‚ โ””โ”€โ”€ โ–บ:3[2]:main -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|11) -โ””โ”€โ”€ โ–บ:2[0]:origin/A โ†’:0: - โ””โ”€โ”€ ๐ŸŸฃ4fe5a6f (0x0|10) - โ””โ”€โ”€ โ†’:0: (A โ†’:2:) - -"#]] - ); - - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:A <> origin/Aโ‡ฃ1 - โ”œโ”€โ”€ ๐Ÿ‘‰:A <> origin/Aโ‡ฃ1 - โ”‚ โ”œโ”€โ”€ ๐ŸŸฃ4fe5a6f - โ”‚ โ”œโ”€โ”€ โ„๏ธa62b0de (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ โ„๏ธ120a217 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :main - โ””โ”€โ”€ โ„fafd9d0 (๐Ÿ˜๏ธ) - -"#]] - ); - - Ok(()) -} - -#[test] -fn workspace_commit_pushed_to_target() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/ws-commit-pushed-to-target")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 8ee08de (HEAD -> gitbutler/workspace, origin/main) GitButler Workspace Commit -* 120a217 (A) A1 -* fafd9d0 (main) init - -"#]] - ); - - add_workspace(&mut meta); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:3: - โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[1]:gitbutler/workspace[๐ŸŒณ] - โ””โ”€โ”€ ยท8ee08de (โŒ‚|๐Ÿ˜|โœ“|1) - โ””โ”€โ”€ โ–บ:2[2]:A - โ””โ”€โ”€ ยท120a217 (โŒ‚|๐Ÿ˜|โœ“|1) - โ””โ”€โ”€ โ–บ:3[3]:main <> origin/main โ†’:1: - โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|1) - -"#]] - ); - // Everything is integrated, so nothing is shown. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 120a217 - -"#]] - ); - Ok(()) -} - -#[test] -fn no_workspace_no_target_commit_under_managed_ref() -> anyhow::Result<()> { - let (repo, mut meta) = - read_only_in_memory_scenario("ws/no-ws-no-target-commit-with-managed-ref")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* dca94a4 (HEAD -> gitbutler/workspace) unmanaged -* 120a217 (A) A1 -* fafd9d0 (main) init - -"#]] - ); - - add_workspace(&mut meta); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] - โ””โ”€โ”€ โ–บ:1[1]:anon: - โ””โ”€โ”€ ยทdca94a4 (โŒ‚|๐Ÿ˜|1) - โ””โ”€โ”€ โ–บ:2[2]:A - โ””โ”€โ”€ ยท120a217 (โŒ‚|๐Ÿ˜|1) - โ””โ”€โ”€ โ–บ:3[3]:main - โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|1) - -"#]] - ); - - // It's notable how hard the workspace ref tries to not own the commit - // it's under unless it's a managed commit. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:anon: - โ”œโ”€โ”€ :anon: - โ”‚ โ””โ”€โ”€ ยทdca94a4 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ :A - โ”‚ โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :main - โ””โ”€โ”€ ยทfafd9d0 (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -#[test] -fn no_workspace_commit() -> anyhow::Result<()> { - let (repo, mut meta) = - read_only_in_memory_scenario("ws/multiple-dependent-branches-per-stack-without-ws-commit")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* cbc6713 (HEAD -> gitbutler/workspace, lane) change -* fafd9d0 (origin/main, main, lane-segment-02, lane-segment-01, lane-2-segment-02, lane-2-segment-01, lane-2) init - -"#]] - ); - - // Follow the natural order, lane first. - add_stack_with_segments( - &mut meta, - 0, - "lane", - StackState::InWorkspace, - &["lane-segment-01", "lane-segment-02"], - ); - add_stack_with_segments( - &mut meta, - 1, - "lane-2", - StackState::InWorkspace, - &["lane-2-segment-01", "lane-2-segment-02"], - ); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - // Notably we also pick up 'lane' which sits on the base. - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:3[1]:lane -โ”‚ โ”‚ โ””โ”€โ”€ ยทcbc6713 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:7[2]:lane-segment-01 -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:8[3]:lane-segment-02 -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:2[4]:main <> origin/main โ†’:1: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:4[1]:lane-2 -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:5[2]:lane-2-segment-01 -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:6[3]:lane-2-segment-02 -โ”‚ โ””โ”€โ”€ โ†’:2: (main โ†’:1:) -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ โ†’:2: (main โ†’:1:) - -"#]] - ); - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:lane on fafd9d0 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:lane -โ”‚ โ”‚ โ””โ”€โ”€ ยทcbc6713 (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:lane-segment-01 -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:lane-segment-02 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:lane-2 on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:lane-2 - โ”œโ”€โ”€ ๐Ÿ“™:lane-2-segment-01 - โ””โ”€โ”€ ๐Ÿ“™:lane-2-segment-02 - -"#]] - ); - - // Natural order here is `lane` first, but we say we want `lane-2` first - meta.data_mut().branches.clear(); - add_stack_with_segments( - &mut meta, - 0, - "lane-2", - StackState::InWorkspace, - &["lane-2-segment-01", "lane-2-segment-02"], - ); - add_stack_with_segments( - &mut meta, - 1, - "lane", - StackState::InWorkspace, - &["lane-segment-01", "lane-segment-02"], - ); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - // the order is maintained as provided in the workspace. - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:4[1]:lane-2 -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:5[2]:lane-2-segment-01 -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:6[3]:lane-2-segment-02 -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:2[4]:main <> origin/main โ†’:1: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:3[1]:lane -โ”‚ โ””โ”€โ”€ ยทcbc6713 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:7[2]:lane-segment-01 -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:8[3]:lane-segment-02 -โ”‚ โ””โ”€โ”€ โ†’:2: (main โ†’:1:) -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ โ†’:2: (main โ†’:1:) - -"#]] - ); - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:lane-2 on fafd9d0 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:lane-2 -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:lane-2-segment-01 -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:lane-2-segment-02 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:lane on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:lane - โ”‚ โ””โ”€โ”€ ยทcbc6713 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ๐Ÿ“™:lane-segment-01 - โ””โ”€โ”€ ๐Ÿ“™:lane-segment-02 - -"#]] - ); - Ok(()) -} - -#[test] -fn two_dependent_branches_first_merged_by_rebase() -> anyhow::Result<()> { - let (repo, mut meta) = - read_only_in_memory_scenario("ws/two-dependent-branches-first-rebased-and-merged")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 0b6b861 (origin/main, origin/A) A -| * 4f08b8d (HEAD -> gitbutler/workspace) GitButler Workspace Commit -| * da597e8 (B) B -| * 1818c17 (A) A -|/ -* 281456a (main) init - -"#]] - ); - - add_workspace(&mut meta); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท4f08b8d (โŒ‚|๐Ÿ˜|0001) -โ”‚ โ””โ”€โ”€ โ–บ:3[1]:B -โ”‚ โ””โ”€โ”€ ยทda597e8 (โŒ‚|๐Ÿ˜|0001) -โ”‚ โ””โ”€โ”€ โ–บ:4[2]:A <> origin/A โ†’:5: -โ”‚ โ””โ”€โ”€ ยท1818c17 (โŒ‚|๐Ÿ˜|0101) -โ”‚ โ””โ”€โ”€ โ–บ:2[3]:main <> origin/main โ†’:1: -โ”‚ โ””โ”€โ”€ ๐Ÿยท281456a (โŒ‚|๐Ÿ˜|โœ“|1111) -โ””โ”€โ”€ โ–บ:5[0]:origin/A โ†’:4: - โ””โ”€โ”€ โ–บ:1[1]:origin/main โ†’:2: - โ””โ”€โ”€ ๐ŸŸฃ0b6b861 (โœ“|1000) - โ””โ”€โ”€ โ†’:2: (main โ†’:1:) - -"#]] - ); - - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 281456a -โ””โ”€โ”€ โ‰ก:B on 281456a - โ”œโ”€โ”€ :B - โ”‚ โ””โ”€โ”€ ยทda597e8 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :A <> origin/Aโ‡ก1โ‡ฃ1 - โ”œโ”€โ”€ ๐ŸŸฃ0b6b861 (โœ“) - โ””โ”€โ”€ ยท1818c17 (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -#[test] -fn special_branch_names_do_not_end_up_in_segment() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/special-branches")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 8926b15 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -* 3686017 (main) top -* 9725482 (gitbutler/edit) middle -* fafd9d0 (gitbutler/target) init - -"#]] - ); - - add_workspace(&mut meta); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - // Standard handling after traversal and post-processing. - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] - โ””โ”€โ”€ ยท8926b15 (โŒ‚|๐Ÿ˜|1) - โ””โ”€โ”€ โ–บ:1[1]:main - โ””โ”€โ”€ ยท3686017 (โŒ‚|๐Ÿ˜|1) - โ””โ”€โ”€ โ–บ:2[2]:gitbutler/edit - โ””โ”€โ”€ ยท9725482 (โŒ‚|๐Ÿ˜|1) - โ””โ”€โ”€ โ–บ:3[3]:gitbutler/target - โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|1) - -"#]] - ); - - // But special handling for workspace views. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:main - โ””โ”€โ”€ :main - โ”œโ”€โ”€ ยท3686017 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ยท9725482 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ยทfafd9d0 (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -#[test] -fn special_branch_do_not_allow_overly_long_segments() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/special-branches-edgecase")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 270738b (HEAD -> gitbutler/workspace) GitButler Workspace Commit -* c59457b (A) top -* e146f13 (gitbutler/edit) middle -* 971953d (origin/main, main) M2 -* ce09734 (origin/gitbutler/target, gitbutler/target) M1 -* fafd9d0 init - -"#]] - ); - - add_workspace(&mut meta); - let mut md = meta.workspace("refs/heads/gitbutler/workspace".try_into()?)?; - let mut project_meta = md.project_meta(); - project_meta.target_ref = Some("refs/remotes/origin/gitbutler/target".try_into()?); - md.set_project_meta(project_meta); - meta.set_workspace(&md)?; - - let graph = Graph::from_head( - &repo, - &*meta, - md.project_meta(), - // standard_options_with_extra_target(&repo, "gitbutler/target"), - standard_options(), - )? - .validated()?; - // Standard handling after traversal and post-processing. - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท270738b (โŒ‚|๐Ÿ˜|001) -โ”‚ โ””โ”€โ”€ โ–บ:3[1]:A -โ”‚ โ””โ”€โ”€ ยทc59457b (โŒ‚|๐Ÿ˜|001) -โ”‚ โ””โ”€โ”€ โ–บ:4[2]:gitbutler/edit -โ”‚ โ””โ”€โ”€ ยทe146f13 (โŒ‚|๐Ÿ˜|001) -โ”‚ โ””โ”€โ”€ โ–บ:5[3]:main <> origin/main โ†’:6: -โ”‚ โ””โ”€โ”€ ยท971953d (โŒ‚|๐Ÿ˜|101) -โ”‚ โ””โ”€โ”€ โ–บ:2[4]:gitbutler/target <> origin/gitbutler/target โ†’:1: -โ”‚ โ”œโ”€โ”€ ยทce09734 (โŒ‚|๐Ÿ˜|โœ“|111) -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|111) -โ”œโ”€โ”€ โ–บ:1[0]:origin/gitbutler/target โ†’:2: -โ”‚ โ””โ”€โ”€ โ†’:2: (gitbutler/target โ†’:1:) -โ””โ”€โ”€ โ–บ:6[0]:origin/main โ†’:5: - โ””โ”€โ”€ โ†’:5: (main โ†’:6:) - -"#]] - ); - - // But special handling for workspace views. Note how we don't overshoot - // and stop exactly where we have to, magically even. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/gitbutler/target on ce09734 -โ””โ”€โ”€ โ‰ก:A on ce09734 - โ”œโ”€โ”€ :A - โ”‚ โ”œโ”€โ”€ ยทc59457b (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ ยทe146f13 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :main <> origin/main - โ””โ”€โ”€ โ„๏ธ971953d (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -#[test] -fn branch_ahead_of_workspace() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/branches-ahead-of-workspace")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 790a17d (C-bottom) C2 merge commit -|\ -| * 631be19 (tmp) C1-outside2 -* | 969aaec C1-outside -|/ -| * 71dad1a (D) D2-outside -| | * c83f258 (A) A2-outside -| | | * 27c2545 (origin/A-middle, A-middle) A1-outside -| | | | * c8f73c7 (B-middle) B3-outside -| | | | * ff75b80 (intermediate-branch) B2-outside -| | | | | *-. fe6ba62 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -| | | |_|/|\ \ -| | |/| | | |/ -| | |_|_|_|/| -| |/| | | | | -| * | | | | | ed36e3b (new-name-for-D) D1 -| | | | | | * 3f7c4e6 (C) C2 -| |_|_|_|_|/ -|/| | | | | -* | | | | | b6895d7 C1 -|/ / / / / -| | | | * 2f8f06d (B) B3 -| | | |/ -| | | | * 867927f (origin/main, main) Merge branch 'B-middle' -| | | | |\ -| | | | |/ -| | | |/| -| | | * | 91bc3fc (origin/B-middle) B2 -| | | * | cf9330f B1 -| |_|/ / -|/| | | -| | | * 6e03461 Merge branch 'A' -| |_|/| -|/| |/ -| |/| -| * | a62b0de A2 -| |/ -| * 120a217 A1 -|/ -* fafd9d0 init - -"#]] - .raw() - ); - - add_workspace(&mut meta); - let graph = Graph::from_head( - &repo, - &*meta, - project_meta(&*meta), - standard_options_with_extra_target(&repo, "main"), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยทfe6ba62 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”œโ”€โ”€ โ–บ:5[3]:anon: -โ”‚ โ”‚ โ”œโ”€โ”€ ยทa62b0de (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ”‚ โ””โ”€โ”€ ยท120a217 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:9[4]:anon: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ”œโ”€โ”€ โ–บ:6[1]:B -โ”‚ โ”‚ โ””โ”€โ”€ ยท2f8f06d (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:4[2]:anon: -โ”‚ โ”‚ โ”œโ”€โ”€ ยท91bc3fc (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ”‚ โ””โ”€โ”€ ยทcf9330f (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ”‚ โ””โ”€โ”€ โ†’:9: -โ”‚ โ”œโ”€โ”€ โ–บ:7[1]:C -โ”‚ โ”‚ โ”œโ”€โ”€ ยท3f7c4e6 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ””โ”€โ”€ ยทb6895d7 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ””โ”€โ”€ โ†’:9: -โ”‚ โ””โ”€โ”€ โ–บ:8[1]:new-name-for-D -โ”‚ โ””โ”€โ”€ ยทed36e3b (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ†’:9: -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ โ–บ:2[1]:main <> origin/main โ†’:1: - โ””โ”€โ”€ ยท867927f (โŒ‚|โœ“|10) - โ”œโ”€โ”€ โ–บ:3[2]:anon: - โ”‚ โ””โ”€โ”€ ยท6e03461 (โŒ‚|โœ“|10) - โ”‚ โ”œโ”€โ”€ โ†’:9: - โ”‚ โ””โ”€โ”€ โ†’:5: - โ””โ”€โ”€ โ†’:4: - -"#]] - ); - - // If it doesn't know how the workspace should be looking like, i.e. which branches are contained, - // nothing special happens. - // The branches that are outside the workspace don't exist and segments are flattened. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ2 on fafd9d0 -โ”œโ”€โ”€ โ‰ก:B on fafd9d0 -โ”‚ โ””โ”€โ”€ :B -โ”‚ โ””โ”€โ”€ ยท2f8f06d (๐Ÿ˜๏ธ) -โ”œโ”€โ”€ โ‰ก:C on fafd9d0 -โ”‚ โ””โ”€โ”€ :C -โ”‚ โ”œโ”€โ”€ ยท3f7c4e6 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ยทb6895d7 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก:new-name-for-D on fafd9d0 - โ””โ”€โ”€ :new-name-for-D - โ””โ”€โ”€ ยทed36e3b (๐Ÿ˜๏ธ) - -"#]] - ); - - // However, when the desired workspace is set up, the traversal will include these extra tips. - add_stack_with_segments(&mut meta, 0, "A", StackState::InWorkspace, &["A-middle"]); - add_stack_with_segments(&mut meta, 1, "B", StackState::InWorkspace, &["B-middle"]); - add_stack_with_segments(&mut meta, 2, "C", StackState::InWorkspace, &["C-bottom"]); - add_stack_with_segments(&mut meta, 3, "D", StackState::InWorkspace, &[]); - - let graph = Graph::from_head( - &repo, - &*meta, - project_meta(&*meta), - standard_options_with_extra_target(&repo, ":/init"), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยทfe6ba62 (โŒ‚|๐Ÿ˜|00001) -โ”‚ โ”œโ”€โ”€ โ–บ:19[3]:anon: โ†’:4: -โ”‚ โ”‚ โ””โ”€โ”€ ยทa62b0de (โŒ‚|๐Ÿ˜|โœ“|00011) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:21[4]:anon: โ†’:5: -โ”‚ โ”‚ โ””โ”€โ”€ ยท120a217 (โŒ‚|๐Ÿ˜|โœ“|00111) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:3[5]:anon: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|11111) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:6[1]:B -โ”‚ โ”‚ โ””โ”€โ”€ ยท2f8f06d (โŒ‚|๐Ÿ˜|00001) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:15[2]:anon: โ†’:7: -โ”‚ โ”‚ โ”œโ”€โ”€ ยท91bc3fc (โŒ‚|๐Ÿ˜|โœ“|11011) -โ”‚ โ”‚ โ””โ”€โ”€ ยทcf9330f (โŒ‚|๐Ÿ˜|โœ“|11011) -โ”‚ โ”‚ โ””โ”€โ”€ โ†’:3: -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:8[1]:C -โ”‚ โ”‚ โ””โ”€โ”€ ยท3f7c4e6 (โŒ‚|๐Ÿ˜|00001) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:20[2]:anon: โ†’:9: -โ”‚ โ”‚ โ””โ”€โ”€ ยทb6895d7 (โŒ‚|๐Ÿ˜|00001) -โ”‚ โ”‚ โ””โ”€โ”€ โ†’:3: -โ”‚ โ””โ”€โ”€ โ–บ:18[1]:new-name-for-D -โ”‚ โ””โ”€โ”€ ยทed36e3b (โŒ‚|๐Ÿ˜|00001) -โ”‚ โ””โ”€โ”€ โ†’:3: -โ”œโ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ โ–บ:2[1]:main <> origin/main โ†’:1: -โ”‚ โ””โ”€โ”€ ยท867927f (โŒ‚|โœ“|00010) -โ”‚ โ”œโ”€โ”€ โ–บ:13[2]:anon: -โ”‚ โ”‚ โ””โ”€โ”€ ยท6e03461 (โŒ‚|โœ“|00010) -โ”‚ โ”‚ โ”œโ”€โ”€ โ†’:3: -โ”‚ โ”‚ โ””โ”€โ”€ โ†’:19: -โ”‚ โ””โ”€โ”€ โ†’:15: -โ”œโ”€โ”€ ๐Ÿ“™โ–บ:4[0]:A -โ”‚ โ””โ”€โ”€ ยทc83f258 (โŒ‚) -โ”‚ โ””โ”€โ”€ โ†’:19: -โ”œโ”€โ”€ ๐Ÿ“™โ–บ:7[0]:B-middle <> origin/B-middle โ†’:12: -โ”‚ โ””โ”€โ”€ ยทc8f73c7 (โŒ‚|01000) -โ”‚ โ””โ”€โ”€ โ–บ:16[1]:intermediate-branch -โ”‚ โ””โ”€โ”€ ยทff75b80 (โŒ‚|01000) -โ”‚ โ””โ”€โ”€ โ†’:15: -โ”œโ”€โ”€ ๐Ÿ“™โ–บ:9[0]:C-bottom -โ”‚ โ””โ”€โ”€ ยท790a17d (โŒ‚) -โ”‚ โ”œโ”€โ”€ โ–บ:17[1]:anon: -โ”‚ โ”‚ โ””โ”€โ”€ ยท969aaec (โŒ‚) -โ”‚ โ”‚ โ””โ”€โ”€ โ†’:20: -โ”‚ โ””โ”€โ”€ โ–บ:14[1]:tmp -โ”‚ โ””โ”€โ”€ ยท631be19 (โŒ‚) -โ”‚ โ””โ”€โ”€ โ†’:20: -โ”œโ”€โ”€ ๐Ÿ“™โ–บ:10[0]:D -โ”‚ โ””โ”€โ”€ ยท71dad1a (โŒ‚) -โ”‚ โ””โ”€โ”€ โ†’:18: (new-name-for-D) -โ”œโ”€โ”€ โ–บ:11[0]:origin/A-middle โ†’:5: -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:5[1]:A-middle <> origin/A-middle โ†’:11: -โ”‚ โ””โ”€โ”€ ยท27c2545 (โŒ‚|00100) -โ”‚ โ””โ”€โ”€ โ†’:21: -โ””โ”€โ”€ โ–บ:12[0]:origin/B-middle โ†’:7: - โ””โ”€โ”€ โ†’:15: - -"#]] - ); - - // The workspace itself contains information about the outside tips. - // We collect it no matter the location of the tip, e.g. - // - anon segment directly below the workspace commit - // - middle anon segment leading to the named branch over intermediate branches - // - middle anon segment leading to the named branch over two outgoing connections - // - except: if the segment with a known named segment in its future has a (new) name, - // we leave it and don't attempt to reconstruct the original (out-of-workspace) reference - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ2 on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A -โ”‚ โ”‚ โ”œโ”€โ”€ ยทc83f258* -โ”‚ โ”‚ โ””โ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ|โœ“) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A-middle <> origin/A-middle -โ”‚ โ”œโ”€โ”€ ยท27c2545* -โ”‚ โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ|โœ“) -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on fafd9d0 {1} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B -โ”‚ โ”‚ โ””โ”€โ”€ ยท2f8f06d (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B-middle <> origin/B-middle -โ”‚ โ”œโ”€โ”€ ยทc8f73c7* -โ”‚ โ”œโ”€โ”€ ยทff75b80* -โ”‚ โ”œโ”€โ”€ ยท91bc3fc (๐Ÿ˜๏ธ|โœ“) -โ”‚ โ””โ”€โ”€ ยทcf9330f (๐Ÿ˜๏ธ|โœ“) -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on fafd9d0 {2} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C -โ”‚ โ”‚ โ””โ”€โ”€ ยท3f7c4e6 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:C-bottom -โ”‚ โ”œโ”€โ”€ ยท790a17d* -โ”‚ โ”œโ”€โ”€ ยท969aaec* -โ”‚ โ”œโ”€โ”€ ยท631be19* -โ”‚ โ””โ”€โ”€ ยทb6895d7 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก:new-name-for-D on fafd9d0 - โ””โ”€โ”€ :new-name-for-D - โ””โ”€โ”€ ยทed36e3b (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -#[test] -fn two_branches_one_advanced_two_parent_ws_commit_diverged_ttb() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario( - "ws/two-branches-one-advanced-two-parent-ws-commit-diverged-ttb", - )?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 873d056 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -|\ -* | cbc6713 (advanced-lane) change -|/ -* fafd9d0 (main, lane) init -* da83717 (origin/main) disjoint remote target - -"#]] - .raw() - ); - - for (idx, name) in ["lane", "advanced-lane"].into_iter().enumerate() { - add_stack_with_segments(&mut meta, idx, name, StackState::InWorkspace, &[]); - } - - let (id, ref_name) = id_at(&repo, "lane"); - let graph = Graph::from_commit_traversal( - id, - ref_name, - &*meta, - project_meta(&*meta), - standard_options_with_extra_target(&repo, "main"), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท873d056 (โŒ‚|๐Ÿ˜) -โ”‚ โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“™โ–บ:4[1]:lane -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:0[2]:anon: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|1) โ–บmain -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:3[1]:advanced-lane -โ”‚ โ””โ”€โ”€ ยทcbc6713 (โŒ‚|๐Ÿ˜) -โ”‚ โ””โ”€โ”€ โ†’:0: -โ””โ”€โ”€ โ–บ:2[0]:origin/main - โ””โ”€โ”€ ๐Ÿ๐ŸŸฃda83717 (โœ“) - -"#]] - ); - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:lane on fafd9d0 {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:lane -โ””โ”€โ”€ โ‰ก๐Ÿ“™:advanced-lane on fafd9d0 {1} - โ””โ”€โ”€ ๐Ÿ“™:advanced-lane - โ””โ”€โ”€ ยทcbc6713 (๐Ÿ˜๏ธ) - -"#]] - ); - - let graph = Graph::from_head( - &repo, - &*meta, - project_meta(&*meta), - standard_options_with_extra_target(&repo, "main"), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท873d056 (โŒ‚|๐Ÿ˜|1) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:4[1]:lane -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:2[2]:anon: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|1) โ–บmain -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:3[1]:advanced-lane -โ”‚ โ””โ”€โ”€ ยทcbc6713 (โŒ‚|๐Ÿ˜|1) -โ”‚ โ””โ”€โ”€ โ†’:2: -โ””โ”€โ”€ โ–บ:1[0]:origin/main - โ””โ”€โ”€ ๐Ÿ๐ŸŸฃda83717 (โœ“) - -"#]] - ); - - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:lane on fafd9d0 {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:lane -โ””โ”€โ”€ โ‰ก๐Ÿ“™:advanced-lane on fafd9d0 {1} - โ””โ”€โ”€ ๐Ÿ“™:advanced-lane - โ””โ”€โ”€ ยทcbc6713 (๐Ÿ˜๏ธ) - -"#]] - ); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท873d056 (โŒ‚|๐Ÿ˜|1) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:4[1]:lane -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:2[2]:anon: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|1) โ–บmain -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:3[1]:advanced-lane -โ”‚ โ””โ”€โ”€ ยทcbc6713 (โŒ‚|๐Ÿ˜|1) -โ”‚ โ””โ”€โ”€ โ†’:2: -โ””โ”€โ”€ โ–บ:1[0]:origin/main - โ””โ”€โ”€ ๐Ÿ๐ŸŸฃda83717 (โœ“) - -"#]] - ); - - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:lane on fafd9d0 {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:lane -โ””โ”€โ”€ โ‰ก๐Ÿ“™:advanced-lane on fafd9d0 {1} - โ””โ”€โ”€ ๐Ÿ“™:advanced-lane - โ””โ”€โ”€ ยทcbc6713 (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -#[test] -fn advanced_workspace_ref() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/advanced-workspace-ref")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* a7131b1 (HEAD -> gitbutler/workspace) on-top4 -* 4d3831e (intermediate-ref) on-top3 -* 468357f on-top2-merge -|\ -| * d3166f7 (branch-on-top) on-top-sibling -|/ -* 118ddbb on-top1 -* 619d548 GitButler Workspace Commit -|\ -| * 6fdab32 (A) A1 -* | 8a352d5 (B) B1 -|/ -* bce0c5e (origin/main, main) M2 -* 3183e43 M1 - -"#]] - .raw() - ); - - add_stack_with_segments(&mut meta, 0, "A", StackState::InWorkspace, &[]); - add_stack_with_segments(&mut meta, 1, "B", StackState::InWorkspace, &[]); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ โ–บ:5[1]:anon: -โ”‚ โ””โ”€โ”€ ยทa7131b1 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:6[2]:intermediate-ref -โ”‚ โ””โ”€โ”€ ยท4d3831e (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:7[3]:anon: -โ”‚ โ””โ”€โ”€ ยท468357f (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”œโ”€โ”€ โ–บ:8[5]:anon: -โ”‚ โ”‚ โ””โ”€โ”€ ยท118ddbb (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:10[6]:anon: -โ”‚ โ”‚ โ””โ”€โ”€ ยท619d548 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:4[7]:B -โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ ยท8a352d5 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:2[8]:main <> origin/main โ†’:1: -โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ ยทbce0c5e (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:3[7]:A -โ”‚ โ”‚ โ””โ”€โ”€ ยท6fdab32 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ””โ”€โ”€ โ†’:2: (main โ†’:1:) -โ”‚ โ””โ”€โ”€ โ–บ:9[4]:branch-on-top -โ”‚ โ””โ”€โ”€ ยทd3166f7 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ†’:8: -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ โ†’:2: (main โ†’:1:) - -"#]] - ); - - // We show the original 'native' configuration without pruning anything, even though - // it contains the workspace commit 619d548. - // It's up to the caller to deal with this situation as the workspace now is marked differently. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ””โ”€โ”€ โ‰ก:anon: on bce0c5e {1} - โ”œโ”€โ”€ :anon: - โ”‚ โ””โ”€โ”€ ยทa7131b1 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ :intermediate-ref - โ”‚ โ”œโ”€โ”€ ยท4d3831e (๐Ÿ˜๏ธ) - โ”‚ โ”œโ”€โ”€ ยท468357f (๐Ÿ˜๏ธ) - โ”‚ โ”œโ”€โ”€ ยท118ddbb (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ ยท619d548 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยท8a352d5 (๐Ÿ˜๏ธ) - -"#]] - ); - - let graph = Graph::from_head( - &repo, - &*meta, - project_meta(&*meta), - standard_options_with_extra_target(&repo, "main"), - )? - .validated()?; - // The extra-target as would happen in the typical case would change nothing though. - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ โ–บ:5[1]:anon: -โ”‚ โ””โ”€โ”€ ยทa7131b1 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:6[2]:intermediate-ref -โ”‚ โ””โ”€โ”€ ยท4d3831e (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:7[3]:anon: -โ”‚ โ””โ”€โ”€ ยท468357f (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”œโ”€โ”€ โ–บ:8[5]:anon: -โ”‚ โ”‚ โ””โ”€โ”€ ยท118ddbb (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:10[6]:anon: -โ”‚ โ”‚ โ””โ”€โ”€ ยท619d548 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:4[7]:B -โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ ยท8a352d5 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:2[8]:main <> origin/main โ†’:1: -โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ ยทbce0c5e (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:3[7]:A -โ”‚ โ”‚ โ””โ”€โ”€ ยท6fdab32 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ””โ”€โ”€ โ†’:2: (main โ†’:1:) -โ”‚ โ””โ”€โ”€ โ–บ:9[4]:branch-on-top -โ”‚ โ””โ”€โ”€ ยทd3166f7 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ†’:8: -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ โ†’:2: (main โ†’:1:) - -"#]] - ); - - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ””โ”€โ”€ โ‰ก:anon: on bce0c5e {1} - โ”œโ”€โ”€ :anon: - โ”‚ โ””โ”€โ”€ ยทa7131b1 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ :intermediate-ref - โ”‚ โ”œโ”€โ”€ ยท4d3831e (๐Ÿ˜๏ธ) - โ”‚ โ”œโ”€โ”€ ยท468357f (๐Ÿ˜๏ธ) - โ”‚ โ”œโ”€โ”€ ยท118ddbb (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ ยท619d548 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยท8a352d5 (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -#[test] -fn advanced_workspace_ref_single_stack() -> anyhow::Result<()> { - let (repo, mut meta) = - read_only_in_memory_scenario("ws/advanced-workspace-ref-and-single-stack")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* da912a8 (HEAD -> gitbutler/workspace) on-top4 -* 198eaf8 (intermediate-ref) on-top3 -* 3147997 on-top2-merge -|\ -| * dd7bb9a (branch-on-top) on-top-sibling -|/ -* 9785229 on-top1 -* c58f157 GitButler Workspace Commit -* 6fdab32 (A) A1 -* bce0c5e (origin/main, main) M2 -* 3183e43 M1 - -"#]] - .raw() - ); - - add_stack_with_segments(&mut meta, 0, "A", StackState::InWorkspace, &[]); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ โ–บ:4[1]:anon: -โ”‚ โ””โ”€โ”€ ยทda912a8 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:5[2]:intermediate-ref -โ”‚ โ””โ”€โ”€ ยท198eaf8 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:6[3]:anon: -โ”‚ โ””โ”€โ”€ ยท3147997 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”œโ”€โ”€ โ–บ:7[5]:anon: -โ”‚ โ”‚ โ”œโ”€โ”€ ยท9785229 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ””โ”€โ”€ ยทc58f157 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:3[6]:A -โ”‚ โ”‚ โ””โ”€โ”€ ยท6fdab32 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:2[7]:main <> origin/main โ†’:1: -โ”‚ โ”‚ โ”œโ”€โ”€ ยทbce0c5e (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ””โ”€โ”€ โ–บ:8[4]:branch-on-top -โ”‚ โ””โ”€โ”€ ยทdd7bb9a (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ†’:7: -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ โ†’:2: (main โ†’:1:) - -"#]] - ); - - // Here we'd show what happens if the workspace commit is somewhere in the middle - // of the segment. This is relevant for code trying to find it, which isn't done here. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ””โ”€โ”€ โ‰ก:anon: on bce0c5e {0} - โ”œโ”€โ”€ :anon: - โ”‚ โ””โ”€โ”€ ยทda912a8 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ :intermediate-ref - โ”‚ โ”œโ”€โ”€ ยท198eaf8 (๐Ÿ˜๏ธ) - โ”‚ โ”œโ”€โ”€ ยท3147997 (๐Ÿ˜๏ธ) - โ”‚ โ”œโ”€โ”€ ยท9785229 (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ ยทc58f157 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:A - โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -#[test] -fn shallow_boundary_below_workspace_lower_bound() -> anyhow::Result<()> { - let (repo, mut meta) = named_read_only_in_memory_scenario( - "special-conditions", - "shallow-workspace-boundary-below-lower-bound", - )?; - - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 00e1860 (HEAD -> gitbutler/workspace, origin/gitbutler/workspace, origin/HEAD) GitButler Workspace Commit -* 6507810 (origin/A, A) A1 -* b625665 (origin/main, main) M4 -* a821094 M3 -* bce0c5e (grafted) M2 - -"#]] - ); - - add_stack(&mut meta, 1, "A", StackState::InWorkspace); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] <> origin/gitbutler/workspace -โ”‚ โ””โ”€โ”€ ยท00e1860 (โŒ‚|๐Ÿ˜|001) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:3[1]:A <> origin/A โ†’:4: -โ”‚ โ””โ”€โ”€ ยท6507810 (โŒ‚|๐Ÿ˜|101) -โ”‚ โ””โ”€โ”€ โ–บ:2[2]:main <> origin/main โ†’:1: -โ”‚ โ”œโ”€โ”€ ยทb625665 (โŒ‚|๐Ÿ˜|โœ“|111) -โ”‚ โ”œโ”€โ”€ ยทa821094 (โŒ‚|๐Ÿ˜|โœ“|111) -โ”‚ โ””โ”€โ”€ โ›ฐยทbce0c5e (โŒ‚|๐Ÿ˜|โœ“|โ›ฐ|111) -โ”œโ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ โ†’:2: (main โ†’:1:) -โ””โ”€โ”€ โ–บ:4[0]:origin/A โ†’:3: - โ””โ”€โ”€ โ†’:3: (A โ†’:4:) - -"#]] - ); - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on b625665 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:A <> origin/A on b625665 {1} - โ””โ”€โ”€ ๐Ÿ“™:A <> origin/A - โ””โ”€โ”€ โ„๏ธ6507810 (๐Ÿ˜๏ธ) - -"#]] - ); - - Ok(()) -} - -#[test] -fn shallow_boundary_in_workspace_prevents_lower_bound() -> anyhow::Result<()> { - let (repo, mut meta) = named_read_only_in_memory_scenario( - "special-conditions", - "shallow-workspace-boundary-in-workspace", - )?; - - add_stack(&mut meta, 1, "A", StackState::InWorkspace); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] <> origin/gitbutler/workspace - โ””โ”€โ”€ ยท00e1860 (โŒ‚|๐Ÿ˜|1) - โ””โ”€โ”€ ๐Ÿ“™โ–บ:1[1]:A - โ””โ”€โ”€ โ›ฐยท6507810 (โŒ‚|๐Ÿ˜|โ›ฐ|1) - -"#]] - ); - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก๐Ÿ“™:A {1} - โ””โ”€โ”€ ๐Ÿ“™:A - โ””โ”€โ”€ ยท6507810 (๐Ÿ˜๏ธ|โ›ฐ) - -"#]] - ); - - Ok(()) -} - -#[test] -fn applied_stack_below_explicit_lower_bound() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/two-branches-one-below-base")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* e82dfab (HEAD -> gitbutler/workspace) GitButler Workspace Commit -|\ -| * 6fdab32 (A) A1 -* | 78b1b59 (B) B1 -| | * 938e6f2 (origin/main, main) M4 -| |/ -|/| -* | f52fcec M3 -|/ -* bce0c5e M2 -* 3183e43 M1 - -"#]] - .raw() - ); - - add_workspace(&mut meta); - meta.data_mut().default_target = None; - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] - โ””โ”€โ”€ ยทe82dfab (โŒ‚|๐Ÿ˜|1) - โ”œโ”€โ”€ โ–บ:1[1]:B - โ”‚ โ”œโ”€โ”€ ยท78b1b59 (โŒ‚|๐Ÿ˜|1) - โ”‚ โ””โ”€โ”€ ยทf52fcec (โŒ‚|๐Ÿ˜|1) - โ”‚ โ””โ”€โ”€ โ–บ:3[2]:anon: - โ”‚ โ”œโ”€โ”€ ยทbce0c5e (โŒ‚|๐Ÿ˜|1) - โ”‚ โ””โ”€โ”€ ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|1) - โ””โ”€โ”€ โ–บ:2[1]:A - โ””โ”€โ”€ ยท6fdab32 (โŒ‚|๐Ÿ˜|1) - โ””โ”€โ”€ โ†’:3: - -"#]] - ); - - // The base is automatically set to the lowest one that includes both branches, despite the target. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on bce0c5e -โ”œโ”€โ”€ โ‰ก:B on bce0c5e -โ”‚ โ””โ”€โ”€ :B -โ”‚ โ”œโ”€โ”€ ยท78b1b59 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ยทf52fcec (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก:A on bce0c5e - โ””โ”€โ”€ :A - โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) - -"#]] - ); - - add_stack_with_segments(&mut meta, 0, "A", StackState::InWorkspace, &[]); - add_stack_with_segments(&mut meta, 1, "B", StackState::InWorkspace, &[]); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - // The same is true if stacks are known in workspace metadata. - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยทe82dfab (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:3[1]:A -โ”‚ โ”‚ โ””โ”€โ”€ ยท6fdab32 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:6[3]:anon: -โ”‚ โ”‚ โ”œโ”€โ”€ ยทbce0c5e (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:4[1]:B -โ”‚ โ””โ”€โ”€ ยท78b1b59 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:5[2]:anon: -โ”‚ โ””โ”€โ”€ ยทf52fcec (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ””โ”€โ”€ โ†’:6: -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ โ–บ:2[1]:main <> origin/main โ†’:1: - โ””โ”€โ”€ ยท938e6f2 (โŒ‚|โœ“|10) - โ””โ”€โ”€ โ†’:5: - -"#]] - ); - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on bce0c5e -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on bce0c5e {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on bce0c5e {1} - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยท78b1b59 (๐Ÿ˜๏ธ) - -"#]] - ); - - // Finally, if the extra-target, indicating an old stored base that isn't valid anymore. - let graph = Graph::from_head( - &repo, - &*meta, - project_meta(&*meta), - standard_options_with_extra_target(&repo, ":/M3"), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยทe82dfab (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:4[1]:A -โ”‚ โ”‚ โ””โ”€โ”€ ยท6fdab32 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:6[3]:anon: -โ”‚ โ”‚ โ”œโ”€โ”€ ยทbce0c5e (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:5[1]:B -โ”‚ โ””โ”€โ”€ ยท78b1b59 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:3[2]:anon: -โ”‚ โ””โ”€โ”€ ยทf52fcec (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ””โ”€โ”€ โ†’:6: -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ โ–บ:2[1]:main <> origin/main โ†’:1: - โ””โ”€โ”€ ยท938e6f2 (โŒ‚|โœ“|10) - โ””โ”€โ”€ โ†’:3: - -"#]] - ); - - // The base is still adjusted so it matches the actual stacks. With the extra-target - // resolved as the target commit, the integrated `f52fcec` is at the target and is - // pruned - consistent with the no-extra-target case above. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on bce0c5e -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on bce0c5e {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on f52fcec {1} - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยท78b1b59 (๐Ÿ˜๏ธ) - -"#]] - ); - - Ok(()) -} - -#[test] -fn applied_stack_above_explicit_lower_bound() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/two-branches-one-above-base")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* c5587c9 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -|\ -| * de6d39c (A) A1 -| * a821094 (origin/main, main) M3 -* | ce25240 (B) B1 -|/ -* bce0c5e M2 -* 3183e43 M1 - -"#]] - .raw() - ); - - add_workspace(&mut meta); - meta.data_mut().default_target = None; - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยทc5587c9 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”œโ”€โ”€ โ–บ:1[1]:B -โ”‚ โ”‚ โ””โ”€โ”€ ยทce25240 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:5[3]:anon: -โ”‚ โ”‚ โ”œโ”€โ”€ ยทbce0c5e (โŒ‚|๐Ÿ˜|11) -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|11) -โ”‚ โ””โ”€โ”€ โ–บ:2[1]:A -โ”‚ โ””โ”€โ”€ ยทde6d39c (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:3[2]:main <> origin/main โ†’:4: -โ”‚ โ””โ”€โ”€ ยทa821094 (โŒ‚|๐Ÿ˜|11) -โ”‚ โ””โ”€โ”€ โ†’:5: -โ””โ”€โ”€ โ–บ:4[0]:origin/main โ†’:3: - โ””โ”€โ”€ โ†’:3: (main โ†’:4:) - -"#]] - ); - - // The base is automatically set to the lowest one that includes both branches, despite the target. - // Interestingly, A now gets to see integrated parts of the target branch. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on bce0c5e -โ”œโ”€โ”€ โ‰ก:B on bce0c5e -โ”‚ โ””โ”€โ”€ :B -โ”‚ โ””โ”€โ”€ ยทce25240 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก:A on bce0c5e - โ”œโ”€โ”€ :A - โ”‚ โ””โ”€โ”€ ยทde6d39c (๐Ÿ˜๏ธ) - โ””โ”€โ”€ :main <> origin/main - โ””โ”€โ”€ โ„๏ธa821094 (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -#[test] -fn dependent_branch_on_base() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/dependent-branch-on-base")?; - snapbox::assert_data_eq!(visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -*-. a0385a8 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -|\ \ -| | * 49d4b34 (A) A1 -| |/ -|/| -| * f9e2cb7 (C2-3, C2-2, C2-1, C) C2 -| * aaa195b (C1-3, C1-2, C1-1) C1 -|/ -* 3183e43 (origin/main, main, below-below-C, below-below-B, below-below-A, below-C, below-B, below-A, B) M1 - -"#]].raw()); - - add_stack_with_segments( - &mut meta, - 1, - "A", - StackState::InWorkspace, - &["below-A", "below-below-A"], - ); - add_stack_with_segments( - &mut meta, - 2, - "B", - StackState::InWorkspace, - &["below-B", "below-below-B"], - ); - add_stack_with_segments( - &mut meta, - 3, - "C", - StackState::InWorkspace, - &[ - "C2-1", - "C2-2", - "C2-3", - "C1-3", - "C1-2", - "C1-1", - "below-C", - "below-below-C", - ], - ); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยทa0385a8 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:3[1]:A -โ”‚ โ”‚ โ””โ”€โ”€ ยท49d4b34 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:18[2]:below-A -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:19[3]:below-below-A -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:2[10]:main <> origin/main โ†’:1: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:6[1]:B -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:7[2]:below-B -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:8[3]:below-below-B -โ”‚ โ”‚ โ””โ”€โ”€ โ†’:2: (main โ†’:1:) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:9[1]:C -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:10[2]:C2-1 -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:11[3]:C2-2 -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:12[4]:C2-3 -โ”‚ โ””โ”€โ”€ ยทf9e2cb7 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:13[5]:C1-3 -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:14[6]:C1-2 -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:15[7]:C1-1 -โ”‚ โ””โ”€โ”€ ยทaaa195b (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:16[8]:below-C -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:17[9]:below-below-C -โ”‚ โ””โ”€โ”€ โ†’:2: (main โ†’:1:) -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ โ†’:2: (main โ†’:1:) - -"#]] - ); - - // Both stacks will look the same, with the dependent branch inserted at the very bottom. - let ws = graph.into_workspace()?; - snapbox::assert_data_eq!( - graph_workspace(&ws).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {1} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A -โ”‚ โ”‚ โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:below-below-A -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {2} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-B -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:below-below-B -โ””โ”€โ”€ โ‰ก๐Ÿ“™:C on 3183e43 {3} - โ”œโ”€โ”€ ๐Ÿ“™:C - โ”œโ”€โ”€ ๐Ÿ“™:C2-1 - โ”œโ”€โ”€ ๐Ÿ“™:C2-2 - โ”œโ”€โ”€ ๐Ÿ“™:C2-3 - โ”‚ โ””โ”€โ”€ ยทf9e2cb7 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ๐Ÿ“™:C1-3 - โ”œโ”€โ”€ ๐Ÿ“™:C1-2 - โ”œโ”€โ”€ ๐Ÿ“™:C1-1 - โ”‚ โ””โ”€โ”€ ยทaaa195b (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ๐Ÿ“™:below-C - โ””โ”€โ”€ ๐Ÿ“™:below-below-C - -"#]] - ); - - let wrongly_inactive = StackState::Inactive; - add_stack_with_segments( - &mut meta, - 1, - "A", - wrongly_inactive, - &["below-A", "below-below-A"], - ); - let ws = ws - .graph - .redo_traversal_with_overlay(&repo, &*meta, Overlay::default())? - .into_workspace()?; - // The stack-id could still be found, even though `A` is wrongly marked as outside the workspace. - // Below A doesn't apply as it's marked inactive. - snapbox::assert_data_eq!( - graph_workspace(&ws).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 3183e43 {2} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-B -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:below-below-B -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on 3183e43 {3} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C2-1 -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C2-2 -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C2-3 -โ”‚ โ”‚ โ””โ”€โ”€ ยทf9e2cb7 (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C1-3 -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C1-2 -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C1-1 -โ”‚ โ”‚ โ””โ”€โ”€ ยทaaa195b (๐Ÿ˜๏ธ) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-C -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:below-below-C -โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {1} - โ””โ”€โ”€ ๐Ÿ“™:A - โ””โ”€โ”€ ยท49d4b34 (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -#[test] -fn remote_and_integrated_tracking_branch_on_merge() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/remote-and-integrated-tracking")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* d018f71 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -| * c1e26b0 (origin/main, main) M-advanced -|/ -| * 2181501 (origin/A) A-remote -|/ -* 1ee1e34 (A) M-base -|\ -| * efc3b77 (tmp1) X -* | c822d66 Y -|/ -* bce0c5e M2 -* 3183e43 M1 - -"#]] - .raw() - ); - add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &[]); - - let graph = Graph::from_head( - &repo, - &*meta, - project_meta(&*meta), - standard_options().with_extra_target_commit_id(repo.rev_parse_single("origin/main")?), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 1ee1e34 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:A <> origin/Aโ‡ฃ1 on 1ee1e34 {1} - โ””โ”€โ”€ ๐Ÿ“™:A <> origin/Aโ‡ฃ1 - โ””โ”€โ”€ ๐ŸŸฃ2181501 - -"#]] - ); - - Ok(()) -} - -#[test] -fn remote_and_integrated_tracking_branch_on_linear_segment() -> anyhow::Result<()> { - let (repo, mut meta) = - read_only_in_memory_scenario("ws/remote-and-integrated-tracking-linear")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 21e584f (HEAD -> gitbutler/workspace) GitButler Workspace Commit -| * 8dc508f (origin/main, main) M-advanced -|/ -| * 197ddce (origin/A) A-remote -|/ -* 081bae9 (A) M-base -* 3183e43 M1 - -"#]] - ); - add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &[]); - - let graph = Graph::from_head( - &repo, - &*meta, - project_meta(&*meta), - standard_options().with_extra_target_commit_id(repo.rev_parse_single("origin/main")?), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 081bae9 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:A <> origin/Aโ‡ฃ1 on 081bae9 {1} - โ””โ”€โ”€ ๐Ÿ“™:A <> origin/Aโ‡ฃ1 - โ””โ”€โ”€ ๐ŸŸฃ197ddce - -"#]] - ); - - Ok(()) -} - -#[test] -fn remote_and_integrated_tracking_branch_on_merge_extra_target() -> anyhow::Result<()> { - let (repo, mut meta) = - read_only_in_memory_scenario("ws/remote-and-integrated-tracking-extra-commit")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 5f2810f (HEAD -> gitbutler/workspace) GitButler Workspace Commit -* 9f47a25 (A) A-local -| * c1e26b0 (origin/main, main) M-advanced -|/ -| * 2181501 (origin/A) A-remote -|/ -* 1ee1e34 M-base -|\ -| * efc3b77 (tmp1) X -* | c822d66 Y -|/ -* bce0c5e M2 -* 3183e43 M1 - -"#]] - .raw() - ); - add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &[]); - let graph = Graph::from_head( - &repo, - &*meta, - project_meta(&*meta), - standard_options().with_extra_target_commit_id(repo.rev_parse_single("origin/main")?), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 1ee1e34 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:A <> origin/Aโ‡ก1โ‡ฃ1 on 1ee1e34 {1} - โ””โ”€โ”€ ๐Ÿ“™:A <> origin/Aโ‡ก1โ‡ฃ1 - โ”œโ”€โ”€ ๐ŸŸฃ2181501 - โ””โ”€โ”€ ยท9f47a25 (๐Ÿ˜๏ธ) - -"#]] - ); - - Ok(()) -} - -#[test] -fn unapplied_branch_on_base() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/unapplied-branch-on-base")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* a26ae77 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -* fafd9d0 (origin/main, unapplied, main) init - -"#]] - ); - add_workspace(&mut meta); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยทa26ae77 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:2[1]:main <> origin/main โ†’:1: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|11) โ–บunapplied -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ โ†’:2: (main โ†’:1:) - -"#]] - ); - - // if the branch was never seen, it's not visible as one would expect. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 - -"#]] - ); - - // An applied branch would be present, but has no commit. - add_stack_with_segments(&mut meta, 1, "unapplied", StackState::InWorkspace, &[]); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:unapplied on fafd9d0 {1} - โ””โ”€โ”€ ๐Ÿ“™:unapplied - -"#]] - ); - - // We simulate an unapplied branch on the base by giving it branch metadata, but not listing - // it in the workspace. - add_stack_with_segments(&mut meta, 1, "unapplied", StackState::Inactive, &[]); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - - // This will be an empty workspace. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 - -"#]] - ); - - Ok(()) -} - -#[test] -fn shared_target_base_keeps_exact_target_segment_with_inactive_unapplied_branch() --> anyhow::Result<()> { - let (repo, mut meta) = - read_only_in_memory_scenario("ws/target-shared-with-unapplied-and-origin-head")?; - add_workspace(&mut meta); - add_stack_with_segments(&mut meta, 1, "survivor", StackState::InWorkspace, &[]); - add_stack_with_segments(&mut meta, 2, "unapplied", StackState::Inactive, &[]); - - let target_ref: gix::refs::FullName = "refs/remotes/origin/main".try_into()?; - let target_head_ref: gix::refs::FullName = "refs/remotes/origin/HEAD".try_into()?; - - assert!( - repo.try_find_reference(target_ref.as_ref())?.is_some(), - "fixture must contain {target_ref}", - ); - assert!( - repo.try_find_reference(target_head_ref.as_ref())?.is_some(), - "fixture must contain {target_head_ref}", - ); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท20f65b7 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:3[1]:survivor -โ”‚ โ”œโ”€โ”€ ยท4ca0966 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ ยทa3b180e (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:2[2]:unapplied -โ”‚ โ”œโ”€โ”€ ยทce09734 (โŒ‚|๐Ÿ˜|โœ“|11) โ–บbase-peer, โ–บbase-peer-1, โ–บbase-peer-2, โ–บbase-peer-3, โ–บbase-peer-4, โ–บbase-peer-5, โ–บbase-peer-6, โ–บbase-peer-7, โ–บbase-peer-8 -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|11) -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:4: - โ””โ”€โ”€ โ–บ:4[1]:main <> origin/main โ†’:1: - โ””โ”€โ”€ โ†’:2: (unapplied) - -"#]] - ); - let debug_graph = graph_tree(&graph); - let target_segment = graph - .segment_by_ref_name(target_ref.as_ref()) - .unwrap_or_else(|| { - panic!( - "expected exact target segment for existing ref {target_ref}, graph was:\n{debug_graph}" - ) - }); - - assert!( - target_segment.commits.is_empty(), - "expected exact target segment to stay empty when the target rests on main, graph was:\n{debug_graph}" - ); - - let ws = graph.into_workspace()?; - snapbox::assert_data_eq!( - graph_workspace(&ws).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on ce09734 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:survivor on ce09734 {1} - โ””โ”€โ”€ ๐Ÿ“™:survivor - โ”œโ”€โ”€ ยท4ca0966 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ยทa3b180e (๐Ÿ˜๏ธ) - -"#]] - ); - - assert_eq!( - ws.target_ref.as_ref().map(|t| t.ref_name.as_ref()), - Some(target_ref.as_ref()), - "expected workspace target_ref to resolve from exact target segment" - ); - - // When it's applied, it will show up though. - add_stack_with_segments(&mut meta, 2, "unapplied", StackState::InWorkspace, &[]); - let ws = ws - .graph - .redo_traversal_with_overlay(&repo, &*meta, Overlay::default())? - .into_workspace()?; - snapbox::assert_data_eq!( - graph_workspace(&ws).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on ce09734 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:survivor on ce09734 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:survivor -โ”‚ โ”œโ”€โ”€ ยท4ca0966 (๐Ÿ˜๏ธ) -โ”‚ โ””โ”€โ”€ ยทa3b180e (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:unapplied on ce09734 {2} - โ””โ”€โ”€ ๐Ÿ“™:unapplied - -"#]] - ); - - Ok(()) -} - -#[test] -fn unapplied_branch_on_base_no_target() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/unapplied-branch-on-base")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* a26ae77 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -* fafd9d0 (origin/main, unapplied, main) init - -"#]] - ); - add_workspace(&mut meta); - remove_target(&mut meta); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยทa26ae77 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:2[1]:main <> origin/main โ†’:1: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|11) โ–บunapplied -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ โ†’:2: (main โ†’:1:) - -"#]] - ); - - // the main branch is disambiguated by its remote reference. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! -โ””โ”€โ”€ โ‰ก:main <> origin/main - โ””โ”€โ”€ :main <> origin/main - โ””โ”€โ”€ โ„๏ธfafd9d0 (๐Ÿ˜๏ธ) โ–บunapplied - -"#]] - ); - - // The 'unapplied' branch can be added on top of that, and we make clear we want `main` as well. - add_stack_with_segments(&mut meta, 1, "unapplied", StackState::InWorkspace, &[]); - add_stack_with_segments(&mut meta, 2, "main", StackState::InWorkspace, &[]); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยทa26ae77 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:3[1]:unapplied -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:2[2]:anon: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:4[1]:main <> origin/main โ†’:1: -โ”‚ โ””โ”€โ”€ โ†’:2: -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:4: - โ””โ”€โ”€ โ†’:4: (main โ†’:1:) - -"#]] - ); - - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:unapplied on fafd9d0 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:unapplied -โ””โ”€โ”€ โ‰ก๐Ÿ“™:main <> origin/main on fafd9d0 {2} - โ””โ”€โ”€ ๐Ÿ“™:main <> origin/main - -"#]] - ); - - // We simulate an unapplied branch on the base by giving it branch metadata, but not listing - // it in the workspace. - add_stack_with_segments(&mut meta, 1, "unapplied", StackState::Inactive, &[]); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - - // Now only `main` shows up. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:main <> origin/main on fafd9d0 {2} - โ””โ”€โ”€ ๐Ÿ“™:main <> origin/main - -"#]] - ); - - Ok(()) -} - -#[test] -fn no_ws_commit_two_branches_no_target() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/no-ws-ref-no-ws-commit-two-branches")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* bce0c5e (HEAD -> gitbutler/workspace, origin/main, main, B, A) M2 -* 3183e43 M1 - -"#]] - ); - remove_target(&mut meta); - add_stack_with_segments(&mut meta, 0, "main", StackState::InWorkspace, &[]); - add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &[]); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - // notably the target ref and local tracking branch have sibling links setup - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:3[1]:main <> origin/main โ†’:1: -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:2[2]:anon: -โ”‚ โ”‚ โ””โ”€โ”€ โœ‚ยทbce0c5e (โŒ‚|๐Ÿ˜|โœ“|1) โ–บB -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:4[1]:A -โ”‚ โ””โ”€โ”€ โ†’:2: -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:3: - โ””โ”€โ”€ โ†’:3: (main โ†’:1:) - -"#]] - ); - // sibling links between origin/main and main are also set - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:main <> origin/main {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:main <> origin/main -โ””โ”€โ”€ โ‰ก๐Ÿ“™:A {1} - โ””โ”€โ”€ ๐Ÿ“™:A - -"#]] - ); - Ok(()) -} - -#[test] -fn ambiguous_worktrees() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/ambiguous-worktrees")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* a5f94a2 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -|\ -| * 3e01e28 (B) B -|/ -| * 8dc508f (origin/main, main) M-advanced -|/ -| * 197ddce (origin/A) A-remote -|/ -* 081bae9 (A-outside, A-inside, A) M-base -* 3183e43 M1 - -"#]] - .raw() - ); - - add_stack_with_segments(&mut meta, 0, "A", StackState::InWorkspace, &[]); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ@repo] -โ”‚ โ””โ”€โ”€ ยทa5f94a2 (โŒ‚|๐Ÿ˜|0001) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:6[1]:A <> origin/A โ†’:4: -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:3[2]:anon: -โ”‚ โ”‚ โ”œโ”€โ”€ ยท081bae9 (โŒ‚|๐Ÿ˜|โœ“|1111) โ–บA-inside[๐Ÿ“wt-A-inside], โ–บA-outside[๐Ÿ“wt-A-outside] -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“|1111) -โ”‚ โ””โ”€โ”€ โ–บ:5[1]:B[๐Ÿ“wt-B-inside] -โ”‚ โ””โ”€โ”€ ยท3e01e28 (โŒ‚|๐Ÿ˜|0001) -โ”‚ โ””โ”€โ”€ โ†’:3: -โ”œโ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ โ–บ:2[1]:main <> origin/main โ†’:1: -โ”‚ โ””โ”€โ”€ ยท8dc508f (โŒ‚|โœ“|0010) -โ”‚ โ””โ”€โ”€ โ†’:3: -โ””โ”€โ”€ โ–บ:4[0]:origin/A โ†’:6: - โ””โ”€โ”€ ๐ŸŸฃ197ddce (0x0|1000) - โ””โ”€โ”€ โ†’:3: - -"#]] - ); - - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ@repo] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 081bae9 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A <> origin/Aโ‡ฃ1 on 081bae9 {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A <> origin/Aโ‡ฃ1 -โ”‚ โ””โ”€โ”€ ๐ŸŸฃ197ddce -โ””โ”€โ”€ โ‰ก:B[๐Ÿ“wt-B-inside] on 081bae9 - โ””โ”€โ”€ :B[๐Ÿ“wt-B-inside] - โ””โ”€โ”€ ยท3e01e28 (๐Ÿ˜๏ธ) - -"#]] - ); - - let linked_repo = gix::open_opts( - repo.path() - .parent() - .expect("repository git dir is inside the worktree") - .join("wt-B-inside"), - gix::open::Options::isolated(), - )? - .with_object_memory(); - let graph = Graph::from_head( - &linked_repo, - &*meta, - project_meta(&*meta), - standard_options(), - )? - .validated()?; - // when the graph is built from the B linked worktree repository, the workspace remains visible but the B worktree owns the entrypoint branch - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยทa5f94a2 (โŒ‚|๐Ÿ˜) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:6[1]:A <> origin/A โ†’:5: -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:4[2]:anon: -โ”‚ โ”‚ โ”œโ”€โ”€ ยท081bae9 (โŒ‚|๐Ÿ˜|โœ“|1111) โ–บA-inside[๐Ÿ“wt-A-inside], โ–บA-outside[๐Ÿ“wt-A-outside] -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“|1111) -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[1]:B[๐Ÿ“wt-B-inside@repo] -โ”‚ โ””โ”€โ”€ ยท3e01e28 (โŒ‚|๐Ÿ˜|0001) -โ”‚ โ””โ”€โ”€ โ†’:4: -โ”œโ”€โ”€ โ–บ:2[0]:origin/main โ†’:3: -โ”‚ โ””โ”€โ”€ โ–บ:3[1]:main <> origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ ยท8dc508f (โŒ‚|โœ“|0010) -โ”‚ โ””โ”€โ”€ โ†’:4: -โ””โ”€โ”€ โ–บ:5[0]:origin/A โ†’:6: - โ””โ”€โ”€ ๐ŸŸฃ197ddce (0x0|1000) - โ””โ”€โ”€ โ†’:4: - -"#]] - ); - - // workspace projection should keep the linked-worktree ownership marker on the focused stack while leaving the workspace ref itself unowned - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 081bae9 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A <> origin/Aโ‡ฃ1 on 081bae9 {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A <> origin/Aโ‡ฃ1 -โ”‚ โ””โ”€โ”€ ๐ŸŸฃ197ddce -โ””โ”€โ”€ โ‰ก๐Ÿ‘‰:B[๐Ÿ“wt-B-inside@repo] on 081bae9 - โ””โ”€โ”€ ๐Ÿ‘‰:B[๐Ÿ“wt-B-inside@repo] - โ””โ”€โ”€ ยท3e01e28 (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -#[test] -fn duplicate_parent_connection_from_ws_commit_to_ambiguous_branch_no_advanced_target() --> anyhow::Result<()> { - let (repo, mut meta) = - read_only_in_memory_scenario("ws/duplicate-workspace-connection-no-target")?; - // Note that HEAD isn't actually pointing at origin/main, but twice at main - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* f18d244 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -|\ -* fafd9d0 (origin/main, main, B, A) init - -"#]] - .raw() - ); - - add_stack(&mut meta, 1, "A", StackState::InWorkspace); - // Our graph is incapable of showing these two connections due to traversal - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยทf18d244 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:3[1]:A -โ”‚ โ””โ”€โ”€ โ–บ:2[2]:main <> origin/main โ†’:1: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|11) โ–บB -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ โ†’:2: (main โ†’:1:) - -"#]] - ); - - // Branch should be visible in workspace once. - let ws = graph.into_workspace()?; - snapbox::assert_data_eq!( - graph_workspace(&ws).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} - โ””โ”€โ”€ ๐Ÿ“™:A - -"#]] - ); - - // 'create' a new branch by metadata - add_stack(&mut meta, 2, "B", StackState::InWorkspace); - let ws = ws - .graph - .redo_traversal_with_overlay(&repo, &*meta, Overlay::default())? - .into_workspace()?; - snapbox::assert_data_eq!( - graph_workspace(&ws).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on fafd9d0 {2} - โ””โ”€โ”€ ๐Ÿ“™:B - -"#]] - ); - - // Now pretend it's stacked. - meta.data_mut().branches.clear(); - add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &["B"]); - let ws = ws - .graph - .redo_traversal_with_overlay(&repo, &*meta, Overlay::default())? - .into_workspace()?; - snapbox::assert_data_eq!( - graph_workspace(&ws).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:A - โ””โ”€โ”€ ๐Ÿ“™:B - -"#]] - ); - - Ok(()) -} - -#[test] -fn duplicate_parent_connection_from_ws_commit_to_ambiguous_branch() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/duplicate-workspace-connection")?; - // Note that HEAD isn't actually pointing at origin/main, but twice at main - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* f18d244 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -|\ -| * 12b42b0 (origin/main) RM -|/ -* fafd9d0 (main, B, A) init - -"#]] - .raw() - ); - - add_stack(&mut meta, 1, "A", StackState::InWorkspace); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยทf18d244 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:3[1]:A -โ”‚ โ””โ”€โ”€ โ–บ:2[2]:main <> origin/main โ†’:1: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|11) โ–บB -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ ๐ŸŸฃ12b42b0 (โœ“) - โ””โ”€โ”€ โ†’:2: (main โ†’:1:) - -"#]] - ); - - // Branch should be visible in workspace once. - let ws = graph.into_workspace()?; - snapbox::assert_data_eq!( - graph_workspace(&ws).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} - โ””โ”€โ”€ ๐Ÿ“™:A - -"#]] - ); - - // 'create' a new branch by metadata - add_stack(&mut meta, 2, "B", StackState::InWorkspace); - let ws = ws - .graph - .redo_traversal_with_overlay(&repo, &*meta, Overlay::default())? - .into_workspace()?; - snapbox::assert_data_eq!( - graph_workspace(&ws).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on fafd9d0 {2} - โ””โ”€โ”€ ๐Ÿ“™:B - -"#]] - ); - - // Now pretend it's stacked. - meta.data_mut().branches.clear(); - add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &["B"]); - let ws = ws - .graph - .redo_traversal_with_overlay(&repo, &*meta, Overlay::default())? - .into_workspace()?; - snapbox::assert_data_eq!( - graph_workspace(&ws).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:A - โ””โ”€โ”€ ๐Ÿ“™:B - -"#]] - ); - - // With extra-target these cases work as well - meta.data_mut().branches.clear(); - add_stack(&mut meta, 1, "A", StackState::InWorkspace); - add_stack(&mut meta, 2, "B", StackState::InWorkspace); - let graph = Graph::from_head( - &repo, - &*meta, - project_meta(&*meta), - standard_options_with_extra_target(&repo, "main"), - )?; - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on fafd9d0 {2} - โ””โ”€โ”€ ๐Ÿ“™:B - -"#]] - ); - - meta.data_mut().branches.clear(); - add_stack_with_segments(&mut meta, 1, "A", StackState::InWorkspace, &["B"]); - let graph = Graph::from_head( - &repo, - &*meta, - project_meta(&*meta), - standard_options_with_extra_target(&repo, "main"), - )?; - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:A - โ””โ”€โ”€ ๐Ÿ“™:B - -"#]] - ); - - Ok(()) -} - -mod edit_commit { - use but_graph::Graph; - use but_testsupport::{graph_tree, graph_workspace, visualize_commit_graph_all}; - - use super::project_meta; - use crate::init::{add_workspace, id_at, read_only_in_memory_scenario, standard_options}; - - #[test] - fn applied_stack_below_explicit_lower_bound() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/edit-commit/simple")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 3ea2742 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -* a62b0de (A) A2 -* 120a217 (gitbutler/edit) A1 -* fafd9d0 (origin/main, main) init - -"#]] - ); - - add_workspace(&mut meta); - let graph = Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท3ea2742 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:3[1]:A -โ”‚ โ””โ”€โ”€ ยทa62b0de (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:4[2]:gitbutler/edit -โ”‚ โ””โ”€โ”€ ยท120a217 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:2[3]:main <> origin/main โ†’:1: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|11) -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ โ†’:2: (main โ†’:1:) - -"#]] - ); - - // special branch names are skipped by default and entirely invisible. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:A on fafd9d0 - โ””โ”€โ”€ :A - โ”œโ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) - -"#]] - ); - - // However, if the HEAD points to that referenceโ€ฆ - let (id, ref_name) = id_at(&repo, "gitbutler/edit"); - let graph = Graph::from_commit_traversal( - id, - ref_name, - &*meta, - project_meta(&*meta), - standard_options(), - )? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท3ea2742 (โŒ‚|๐Ÿ˜) -โ”‚ โ””โ”€โ”€ โ–บ:4[1]:A -โ”‚ โ””โ”€โ”€ ยทa62b0de (โŒ‚|๐Ÿ˜) -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[2]:gitbutler/edit -โ”‚ โ””โ”€โ”€ ยท120a217 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:3[3]:main <> origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|11) -โ””โ”€โ”€ โ–บ:2[0]:origin/main โ†’:3: - โ””โ”€โ”€ โ†’:3: (main โ†’:2:) - -"#]] - ); - // โ€ฆthen the segment becomes visible. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:A on fafd9d0 - โ”œโ”€โ”€ :A - โ”‚ โ””โ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ‘‰:gitbutler/edit - โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) - } -} - -/// Complex merge history with origin/main as the target branch. -/// This simulates a real-world scenario where: -/// - origin/main has multiple merged PRs with complex merge history -/// - A local workspace branch exists with uncommitted work -/// - The local stack branches off from an earlier point in history (nightly/0.5.1754) -#[test] -fn complex_merge_history_with_origin_main_target() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/complex-merge-origin-main")?; - snapbox::assert_data_eq!(visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* 4d53bb1 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -* 4eaff93 (reimplement-insert-blank-commit, reconstructed-insert-blank-commit-branch, local-stack) composability improvements -* d19db1d rename reword_commit to commit_reword -* fb0a67e Reimplement insert blank commit -| * e7e93d6 (origin/main, main) Merge pull request #11567 from gitbutlerapp/jt/uhunk2 -| |\ -| | * eadc96a (jt-uhunk2) Address Copilot review -| | * 8db8b43 refactor -| | * 0aa7094 rub: uncommitted hunk to unassigned area -| | * 28a0336 id: ensure that branch IDs work -| |/ -|/| -| * 49b28a4 (tag: nightly/0.5.1755) refactor-remove-unused-css-variables (#11576) -| * d627ca0 Merge pull request #11571 -| |\ -| | * d62ab55 (pr-11571) Restrict visibility of some functions -| |/ -| * 4ad4354 Merge pull request #11574 from Byron/fix -|/| -| * 5de9f4e (byron-fix) Adjust type of ui.check_for_updates_interval_in_seconds -* | 68e62aa (tag: nightly/0.5.1754) Merge pull request #11573 -|\ \ -| |/ -|/| -| * 2d02c78 (pr-11573) fix kiril reword example -|/ -* 322cb14 base -* fafd9d0 init - -"#]].raw()); - - // Add workspace with origin/main as target (not origin/main) - add_workspace(&mut meta); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ10 on 68e62aa -โ””โ”€โ”€ โ‰ก:anon: on 68e62aa - โ””โ”€โ”€ :anon: - โ”œโ”€โ”€ ยท4eaff93 (๐Ÿ˜๏ธ) โ–บlocal-stack, โ–บreconstructed-insert-blank-commit-branch, โ–บreimplement-insert-blank-commit - โ”œโ”€โ”€ ยทd19db1d (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ยทfb0a67e (๐Ÿ˜๏ธ) - -"#]] - ); - - // Also add the local stack as a workspace stack - add_stack_with_segments( - &mut meta, - 0, - "reimplement-insert-blank-commit", - StackState::InWorkspace, - &["reconstructed-insert-blank-commit-branch"], - ); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ10 on 68e62aa -โ””โ”€โ”€ โ‰ก๐Ÿ“™:reimplement-insert-blank-commit on 68e62aa {0} - โ”œโ”€โ”€ ๐Ÿ“™:reimplement-insert-blank-commit - โ””โ”€โ”€ ๐Ÿ“™:reconstructed-insert-blank-commit-branch - โ”œโ”€โ”€ ยท4eaff93 (๐Ÿ˜๏ธ) โ–บlocal-stack - โ”œโ”€โ”€ ยทd19db1d (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ยทfb0a67e (๐Ÿ˜๏ธ) - -"#]] - ); - - Ok(()) -} - -#[test] -fn reproduce_12146() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/reproduce-12146")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* d77ecda (HEAD -> gitbutler/workspace) GitButler Workspace Commit -|\ -| * 7163661 (B) New commit on branch B -|/ -* 81d4e38 (A) add A -* e32cf47 (origin/main, main) add M - -"#]] - .raw() - ); - - add_stack_with_segments(&mut meta, 0, "A", StackState::InWorkspace, &[]); - add_stack_with_segments(&mut meta, 1, "B", StackState::InWorkspace, &[]); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยทd77ecda (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™โ–บ:5[1]:A -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:3[2]:anon: -โ”‚ โ”‚ โ””โ”€โ”€ ยท81d4e38 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:2[3]:main <> origin/main โ†’:1: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทe32cf47 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:4[1]:B -โ”‚ โ””โ”€โ”€ ยท7163661 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ†’:3: -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ โ†’:2: (main โ†’:1:) - -"#]] - ); - - // The sibling ID is not set, and we see only two stacks: B owns 7163661, - // and both A and B include the shared base commit 81d4e38 (A only has 81d4e38). - let ws = &graph.into_workspace()?; - snapbox::assert_data_eq!( - graph_workspace(ws).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e32cf47 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on e32cf47 {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ”‚ โ””โ”€โ”€ ยท81d4e38 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on e32cf47 {1} - โ””โ”€โ”€ ๐Ÿ“™:B - โ”œโ”€โ”€ ยท7163661 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ยท81d4e38 (๐Ÿ˜๏ธ) - -"#]] - ); - - Ok(()) -} - -/// A stack where a local merge commit at the bottom is already integrated into -/// origin/main (the same PR was merged upstream). The merge commit is kept -/// because it is above the workspace target โ€” integrated commits are only -/// pruned at or below the target. -#[test] -fn integrated_merge_at_bottom_is_kept() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/integrated-merge-at-bottom")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 732604f (HEAD -> gitbutler/workspace) GitButler Workspace Commit -* 66ea651 (local-stack) D -* e5a88a7 C -* 0b3ccaf Merge pull request #1 from fix -|\ -| | * f46830d (origin/main, main) Merge pull request #1 from fix -| |/| -|/|/ -| * f5f42e0 (fix) fix -|/ -* fafd9d0 init - -"#]] - .raw() - ); - - add_stack_with_segments(&mut meta, 0, "local-stack", StackState::InWorkspace, &[]); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on f5f42e0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:local-stack {0} - โ””โ”€โ”€ ๐Ÿ“™:local-stack - โ”œโ”€โ”€ ยท66ea651 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ยทe5a88a7 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ยท0b3ccaf (๐Ÿ˜๏ธ) - -"#]] - ); - - Ok(()) -} - -/// A branch that has a commit, merges main into itself, then has another commit. -/// The fork-point approach finds the original divergence point, so all branch -/// commits (including those below the merge-from-main) remain visible. -#[test] -fn merge_from_main_keeps_all_branch_commits() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/merge-from-main-in-branch")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 891e228 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -* cd76046 (my-branch) branch-commit-2 -* f8ff9a3 Merge main into my-branch -|\ -| * ef56fab (origin/main, main) main-advance -* | 6f65768 branch-commit-1 -|/ -* fafd9d0 init - -"#]] - .raw() - ); - - add_stack_with_segments(&mut meta, 0, "my-branch", StackState::InWorkspace, &[]); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท891e228 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ ๐Ÿ“™โ–บ:3[1]:my-branch -โ”‚ โ””โ”€โ”€ ยทcd76046 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:4[2]:anon: -โ”‚ โ””โ”€โ”€ ยทf8ff9a3 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”œโ”€โ”€ โ–บ:5[3]:anon: -โ”‚ โ”‚ โ””โ”€โ”€ ยท6f65768 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:6[4]:anon: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ””โ”€โ”€ โ–บ:2[3]:main <> origin/main โ†’:1: -โ”‚ โ””โ”€โ”€ ยทef56fab (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ””โ”€โ”€ โ†’:6: -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ โ†’:2: (main โ†’:1:) - -"#]] - ); - - // The fork-point approach correctly finds the original divergence point (fafd9d0) - // instead of the moved merge base (ef56fab), so all 3 branch commits are visible: - // branch-commit-2, the merge commit, and branch-commit-1. - let ws = graph.into_workspace()?; - snapbox::assert_data_eq!( - graph_workspace(&ws).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on ef56fab -โ””โ”€โ”€ โ‰ก๐Ÿ“™:my-branch {0} - โ””โ”€โ”€ ๐Ÿ“™:my-branch - โ”œโ”€โ”€ ยทcd76046 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ยทf8ff9a3 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ยท6f65768 (๐Ÿ˜๏ธ) - -"#]] - ); - - Ok(()) -} - -/// A branch whose commits are integrated (reachable from origin/main after -/// upstream merged them) but the workspace target hasn't advanced yet. -/// Integrated commits above the target must be kept so `integrate_upstream` -/// can detect them. Once the target advances past them, they are pruned. -#[test] -fn integrated_commits_above_target_are_kept() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/integrated-above-target")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 7786959 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -| * 1af5972 (origin/main, main) Merge branch my-branch -| |\ -| |/ -|/| -* | 312f819 (my-branch) B -* | e255adc A -|/ -* fafd9d0 init - -"#]] - .raw() - ); - - let init_id = repo.rev_parse_single("main~1")?.detach(); - add_workspace_with_target(&mut meta, init_id); - add_stack_with_segments(&mut meta, 0, "my-branch", StackState::InWorkspace, &[]); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - // With the target at "init", A and B are above the target and should be - // kept even though they are marked integrated. - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on fafd9d0 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:my-branch on fafd9d0 {0} - โ””โ”€โ”€ ๐Ÿ“™:my-branch - โ”œโ”€โ”€ ยท312f819 (๐Ÿ˜๏ธ|โœ“) - โ””โ”€โ”€ ยทe255adc (๐Ÿ˜๏ธ|โœ“) - -"#]] - ); - - // Now advance the target to origin/main (which includes the merge). - // Both commits are at or below the new target and should be pruned, - // but the metadata-tracked branch entry is preserved. - let main_id = repo.rev_parse_single("main")?.detach(); - add_workspace_with_target(&mut meta, main_id); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 312f819 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:my-branch on 312f819 {0} - โ””โ”€โ”€ ๐Ÿ“™:my-branch - -"#]] - ); - - let graph = Graph::from_head( - &repo, - &*meta, - project_meta(&*meta), - standard_options().with_hard_limit(usize::MAX), - )? - .validated()?; - assert!( - !graph.hard_limit_hit(), - "pruning integrated tips should not report a hard-limit traversal stop" - ); - - Ok(()) -} - -/// Regression: an old branch applied below the stored target drags the workspace base -/// below it, exposing the integrated trunk between base and target. Those commits must be -/// pruned even though `origin/main` has advanced past the target - which previously -/// disabled integrated-commit pruning entirely. -#[test] -fn integrated_commits_below_target_pruned_when_upstream_ahead() -> anyhow::Result<()> { - let (repo, mut meta) = - read_only_in_memory_scenario("ws/integrated-below-target-upstream-ahead")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* aca392b (HEAD -> gitbutler/workspace) GitButler Workspace Commit -|\ -| * f458f7d (old-branch) O -* | f5055a1 (my-branch) W -| | * 7282cb5 (origin/main, main) upstream -| |/ -|/| -* | 2121f9c target -|/ -* 322cb14 base -* fafd9d0 init - -"#]] - .raw() - ); - - // Stored target is 'target' (main~1); origin/main is one commit ahead at 'upstream'. - let target_id = repo.rev_parse_single("main~1")?.detach(); - add_workspace_with_target(&mut meta, target_id); - add_stack_with_segments(&mut meta, 0, "my-branch", StackState::InWorkspace, &[]); - add_stack_with_segments(&mut meta, 1, "old-branch", StackState::InWorkspace, &[]); - - // 'W' and 'O' are above/beside the target and kept; 'target' and 'base' are - // integrated and at or below the target, so they are pruned from both stacks - // even though origin/main has advanced past the target. - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 322cb14 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:my-branch on 2121f9c {0} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:my-branch -โ”‚ โ””โ”€โ”€ ยทf5055a1 (๐Ÿ˜๏ธ) -โ””โ”€โ”€ โ‰ก๐Ÿ“™:old-branch on 322cb14 {1} - โ””โ”€โ”€ ๐Ÿ“™:old-branch - โ””โ”€โ”€ ยทf458f7d (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -/// A branch that forks below the target and catches up via `merge origin/main`, so the -/// target enters X only through the merge's second parent (off X's first-parent spine). -/// X is floored at its fork point - where its own first-parent work meets the trunk - so -/// the trunk below the fork (`c1`, `init`) is pruned, leaving X's own commits. -#[test] -fn catchup_merge_below_target_floors_at_fork() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/catchup-merge-leak")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 254106a (HEAD -> gitbutler/workspace) GitButler Workspace Commit -* f210f41 (X) x2 -* f8cd0ce catch up to origin/main -|\ -| * 0975125 (origin/main, main) U -| * a7db886 B -| * d263f88 T -| * 8bd7dc1 c2 -* | 4eec82a x1 -|/ -* b4bd43f c1 -* fafd9d0 init - -"#]] - .raw() - ); - - // Stored target is 'T' (main~2); origin/main is two commits ahead at 'U'. - let target_id = repo.rev_parse_single("main~2")?.detach(); - add_workspace_with_target(&mut meta, target_id); - add_stack_with_segments(&mut meta, 0, "X", StackState::InWorkspace, &[]); - - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on d263f88 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:X on b4bd43f {0} - โ””โ”€โ”€ ๐Ÿ“™:X - โ”œโ”€โ”€ ยทf210f41 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ ยทf8cd0ce (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ยท4eec82a (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -/// A non-workspace ref (tag) points at the workspace commit itself, -/// and that ref is used as the entrypoint for traversal. -/// This verifies that the entrypoint is correctly identified even when it -/// coincides with the workspace commit. -#[test] -fn entrypoint_on_workspace_commit() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/entrypoint-on-workspace-commit")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 3ea2742 (HEAD -> gitbutler/workspace, tag: my-tag) GitButler Workspace Commit -* a62b0de (A) A2 -* 120a217 A1 -* fafd9d0 (origin/main, main) init - -"#]] - ); - - add_workspace(&mut meta); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท3ea2742 (โŒ‚|๐Ÿ˜|01) โ–บtags/my-tag -โ”‚ โ””โ”€โ”€ โ–บ:3[1]:A -โ”‚ โ”œโ”€โ”€ ยทa62b0de (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ ยท120a217 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:2[2]:main <> origin/main โ†’:1: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|11) -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ โ†’:2: (main โ†’:1:) - -"#]] - ); - - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:A on fafd9d0 - โ””โ”€โ”€ :A - โ”œโ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) - -"#]] - ); - - // Now traverse from the tag that points at the workspace commit. - let (id, name) = id_at(&repo, "my-tag"); - let graph = - Graph::from_commit_traversal(id, name, &*meta, project_meta(&*meta), standard_options())? - .validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ โ–บ:0[0]:anon: -โ”‚ โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:5[1]:tags/my-tag -โ”‚ โ””โ”€โ”€ ๐Ÿ“•โ–บโ–บโ–บ:1[2]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท3ea2742 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:4[3]:A -โ”‚ โ”œโ”€โ”€ ยทa62b0de (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ ยท120a217 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:3[4]:main <> origin/main โ†’:2: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|11) -โ””โ”€โ”€ โ–บ:2[0]:origin/main โ†’:3: - โ””โ”€โ”€ โ†’:3: (main โ†’:2:) - -"#]] - ); - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:A on fafd9d0 - โ””โ”€โ”€ :A - โ”œโ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -/// A workspace where the local branch was deleted, leaving only origin/A. -/// The workspace commit still references the old branch tip as a parent. -/// This probes whether a remote-only segment at the top of a stack is handled -/// correctly (previously protected by front-pruning workaround). -#[test] -fn remote_only_stack_top() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/remote-only-stack-top")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 3ea2742 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -* a62b0de (origin/A) A2 -* 120a217 A1 -* fafd9d0 (origin/main, main) init - -"#]] - ); - - add_workspace(&mut meta); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท3ea2742 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:3[1]:anon: -โ”‚ โ”œโ”€โ”€ ยทa62b0de (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ ยท120a217 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:2[2]:main <> origin/main โ†’:1: -โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|11) -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ โ†’:2: (main โ†’:1:) - -"#]] - ); - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:anon: on fafd9d0 - โ””โ”€โ”€ :anon: - โ”œโ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} - -/// A local branch B is stacked on top of a remote-only origin/A (no local A). -/// origin/A's commits are on the first-parent path between B and main. -/// This probes whether a remote-only segment appearing after a local segment -/// in a stack is handled correctly (previously protected by tail-pruning workaround). -#[test] -fn remote_trailing_local_stack() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/remote-trailing-local-stack")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 5638b41 (HEAD -> gitbutler/workspace) GitButler Workspace Commit -* cb7021b (B) B2 -* ce3278a B1 -* a62b0de (origin/A) A2 -* 120a217 A1 -* fafd9d0 (origin/main, main) init - -"#]] - ); - - add_workspace(&mut meta); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท5638b41 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ–บ:3[1]:B -โ”‚ โ”œโ”€โ”€ ยทcb7021b (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ ๐Ÿยทce3278a (โŒ‚|๐Ÿ˜|01) -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ โ–บ:2[1]:main <> origin/main โ†’:1: - โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|โœ“|10) - -"#]] - ); - // this is a weird state as the target is actually disjoint from the workspace - it appears empty now - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on cb7021b - -"#]] - ); - Ok(()) -} - -/// A workspace that merges a remote-only branch (origin/A) with no local counterpart. -/// Unlike `remote_only_stack_top` where the local was deleted after workspace creation, -/// here the local never existed. This tests whether the `is_pruned` check correctly -/// handles a stack that starts with a remote-only segment. -#[test] -fn remote_ref_as_stack_top() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/remote-ref-as-stack-top")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 21bff1f (HEAD -> gitbutler/workspace) GitButler Workspace Commit -|\ -| * a62b0de (origin/A) A2 -| * 120a217 A1 -|/ -* fafd9d0 (origin/main, main) init - -"#]] - .raw() - ); - - add_workspace(&mut meta); - let graph = - Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())?.validated()?; - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ”œโ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:gitbutler/workspace[๐ŸŒณ] -โ”‚ โ””โ”€โ”€ ยท21bff1f (โŒ‚|๐Ÿ˜|01) -โ”‚ โ”œโ”€โ”€ โ–บ:2[2]:main <> origin/main โ†’:1: -โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“|11) -โ”‚ โ””โ”€โ”€ โ–บ:3[1]:anon: -โ”‚ โ”œโ”€โ”€ ยทa62b0de (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ ยท120a217 (โŒ‚|๐Ÿ˜|01) -โ”‚ โ””โ”€โ”€ โ†’:2: (main โ†’:1:) -โ””โ”€โ”€ โ–บ:1[0]:origin/main โ†’:2: - โ””โ”€โ”€ โ†’:2: (main โ†’:1:) - -"#]] - ); - snapbox::assert_data_eq!( - graph_workspace(&graph.into_workspace()?).to_string(), - snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on fafd9d0 -โ””โ”€โ”€ โ‰ก:anon: on fafd9d0 - โ””โ”€โ”€ :anon: - โ”œโ”€โ”€ ยทa62b0de (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ยท120a217 (๐Ÿ˜๏ธ) - -"#]] - ); - Ok(()) -} diff --git a/crates/but-graph/tests/graph/merge_base.rs b/crates/but-graph/tests/graph/merge_base.rs deleted file mode 100644 index ccbc198027c..00000000000 --- a/crates/but-graph/tests/graph/merge_base.rs +++ /dev/null @@ -1,403 +0,0 @@ -use anyhow::Context; -use but_graph::{ - CommitFlags, FirstParent, Graph, Segment, SegmentIndex, SegmentRelation, init::Tip, -}; -use but_testsupport::{graph_tree, visualize_commit_graph_all}; -use snapbox::IntoData; - -use crate::init::{read_only_in_memory_scenario, standard_options}; - -#[test] -fn find_git_merge_base_handles_duplicate_queue_entries_and_redundant_bases() -> anyhow::Result<()> { - let (repo, meta) = read_only_in_memory_scenario("four-diamond")?; - let graph = Graph::from_head( - &repo, - &*meta, - but_core::ref_metadata::ProjectMeta::default(), - standard_options(), - )? - .validated()?; - - let merged = segment_id_by_ref_name(&graph, "refs/heads/merged")?; - let a = segment_id_by_ref_name(&graph, "refs/heads/A")?; - let c = segment_id_by_ref_name(&graph, "refs/heads/C")?; - let main = segment_id_by_ref_name(&graph, "refs/heads/main")?; - - // merged -> (A,C) -> ... -> main causes the walk from merged to queue shared ancestors repeatedly. - assert_eq!(graph.find_merge_base(merged, main), Some(main)); - - // For (merged, A), both A and main are common in ancestry, but A is the nearest one. - assert_eq!(graph.find_merge_base(merged, a), Some(a)); - assert_ne!(graph.find_merge_base(merged, a), Some(main)); - - // Independent branches under the same merge should converge at main. - assert_eq!(graph.find_merge_base(a, c), Some(main)); - assert_eq!(graph.find_merge_base_octopus([a, c, merged]), Some(main)); - - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:merged[๐ŸŒณ] - โ””โ”€โ”€ ยท8a6c109 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:A - โ”‚ โ””โ”€โ”€ ยท62b409a (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ โ–บ:3[2]:anon: - โ”‚ โ”‚ โ””โ”€โ”€ ยท592abec (โŒ‚|1) - โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:7[3]:main - โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท965998b (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[2]:B - โ”‚ โ””โ”€โ”€ ยทf16dddf (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:7: (main) - โ””โ”€โ”€ โ–บ:2[1]:C - โ””โ”€โ”€ ยท7ed512a (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:5[2]:anon: - โ”‚ โ””โ”€โ”€ ยท35ee481 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:7: (main) - โ””โ”€โ”€ โ–บ:6[2]:D - โ””โ”€โ”€ ยทecb1877 (โŒ‚|1) - โ””โ”€โ”€ โ†’:7: (main) - -"#]] - ); - - Ok(()) -} - -#[test] -fn relation_between_matches_merge_base_in_redundant_ancestor_case() -> anyhow::Result<()> { - let (repo, meta) = read_only_in_memory_scenario("four-diamond")?; - let graph = Graph::from_head( - &repo, - &*meta, - but_core::ref_metadata::ProjectMeta::default(), - standard_options(), - )? - .validated()?; - - let merged = segment_id_by_ref_name(&graph, "refs/heads/merged")?; - let a = segment_id_by_ref_name(&graph, "refs/heads/A")?; - let c = segment_id_by_ref_name(&graph, "refs/heads/C")?; - - assert_eq!(graph.relation_between(a, merged), SegmentRelation::Ancestor); - assert_eq!( - graph.relation_between(merged, a), - SegmentRelation::Descendant - ); - assert_eq!(graph.relation_between(a, c), SegmentRelation::Diverged); - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:0[0]:merged[๐ŸŒณ] - โ””โ”€โ”€ ยท8a6c109 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:A - โ”‚ โ””โ”€โ”€ ยท62b409a (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ โ–บ:3[2]:anon: - โ”‚ โ”‚ โ””โ”€โ”€ ยท592abec (โŒ‚|1) - โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:7[3]:main - โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท965998b (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[2]:B - โ”‚ โ””โ”€โ”€ ยทf16dddf (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:7: (main) - โ””โ”€โ”€ โ–บ:2[1]:C - โ””โ”€โ”€ ยท7ed512a (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:5[2]:anon: - โ”‚ โ””โ”€โ”€ ยท35ee481 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:7: (main) - โ””โ”€โ”€ โ–บ:6[2]:D - โ””โ”€โ”€ ยทecb1877 (โŒ‚|1) - โ””โ”€โ”€ โ†’:7: (main) - -"#]] - ); - - Ok(()) -} - -#[test] -fn reachable_difference_returns_commits_in_traversal_order() -> anyhow::Result<()> { - let (repo, meta) = read_only_in_memory_scenario("four-diamond")?; - snapbox::assert_data_eq!( - visualize_commit_graph_all(&repo)?, - snapbox::str![[r#" -* 8a6c109 (HEAD -> merged) Merge branch 'C' into merged -|\ -| * 7ed512a (C) Merge branch 'D' into C -| |\ -| | * ecb1877 (D) D -| * | 35ee481 C -| |/ -* | 62b409a (A) Merge branch 'B' into A -|\ \ -| * | f16dddf (B) B -| |/ -* / 592abec A -|/ -* 965998b (main) base - -"#]] - .raw() - ); - - let graph = Graph::from_head( - &repo, - &*meta, - but_core::ref_metadata::ProjectMeta::default(), - standard_options(), - )? - .validated()?; - - let merged_id = repo.rev_parse_single("merged")?.detach(); - let a_id = repo.rev_parse_single("A")?.detach(); - - let ids = graph.find_commit_ids_reachable_from_a_not_b(merged_id, a_id, FirstParent::No)?; - assert_eq!(ids, ids_by_revs(&repo, &["merged", "C", "C^1", "C^2"])?); - let first_parent_ids = - graph.find_commit_ids_reachable_from_a_not_b(merged_id, a_id, FirstParent::Yes)?; - assert_eq!(first_parent_ids, ids_by_revs(&repo, &["merged"])?); - - let merged = segment_id_by_ref_name(&graph, "refs/heads/merged")?; - let a = segment_id_by_ref_name(&graph, "refs/heads/A")?; - - let commits = graph.find_commits_reachable_from_a_not_b(merged, a, FirstParent::No); - assert_eq!( - commits.iter().map(|commit| commit.id).collect::>(), - ids - ); - let first_parent_commits = - graph.find_commits_reachable_from_a_not_b(merged, a, FirstParent::Yes); - assert_eq!( - first_parent_commits - .iter() - .map(|commit| commit.id) - .collect::>(), - first_parent_ids - ); - assert!( - graph - .find_commit_ids_reachable_from_a_not_b(a_id, a_id, FirstParent::No)? - .is_empty(), - "self-exclusion means nothing is returned" - ); - - Ok(()) -} - -#[test] -fn explicit_traversal_tips_include_unnamed_revisions() -> anyhow::Result<()> { - let (repo, meta) = read_only_in_memory_scenario("four-diamond")?; - let merged_id = repo.rev_parse_single("merged")?.detach(); - let a_id = repo.rev_parse_single("A")?.detach(); - let c_id = repo.rev_parse_single("C")?.detach(); - let main_id = repo.rev_parse_single("main")?.detach(); - - let graph = Graph::from_commit_traversal_tips( - &repo, - [ - Tip::entrypoint(merged_id, None), - Tip::reachable(a_id, None), - Tip::reachable(c_id, None), - ], - &*meta, - but_core::ref_metadata::ProjectMeta::default(), - standard_options(), - )? - .validated()?; - - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:2[0]:merged[๐ŸŒณ] - โ””โ”€โ”€ ยท8a6c109 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:0[1]:A - โ”‚ โ””โ”€โ”€ ยท62b409a (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ โ–บ:3[2]:anon: - โ”‚ โ”‚ โ””โ”€โ”€ ยท592abec (โŒ‚|1) - โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:7[3]:main - โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท965998b (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[2]:B - โ”‚ โ””โ”€โ”€ ยทf16dddf (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:7: (main) - โ””โ”€โ”€ โ–บ:1[1]:C - โ””โ”€โ”€ ยท7ed512a (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:5[2]:anon: - โ”‚ โ””โ”€โ”€ ยท35ee481 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:7: (main) - โ””โ”€โ”€ โ–บ:6[2]:D - โ””โ”€โ”€ ยทecb1877 (โŒ‚|1) - โ””โ”€โ”€ โ†’:7: (main) - -"#]] - ); - - assert_eq!( - graph.find_commit_ids_reachable_from_a_not_b(merged_id, a_id, FirstParent::No)?, - ids_by_revs(&repo, &["merged", "C", "C^1", "C^2"])? - ); - assert_eq!( - graph.find_merge_base_octopus_by_commit_id([a_id, c_id, merged_id])?, - Some(main_id) - ); - - Ok(()) -} - -#[test] -fn explicit_traversal_prioritizes_integrated_tips_independent_of_input_order() -> anyhow::Result<()> -{ - let (repo, meta) = read_only_in_memory_scenario("four-diamond")?; - let merged_id = repo.rev_parse_single("merged")?.detach(); - let a_id = repo.rev_parse_single("A")?.detach(); - let main_id = repo.rev_parse_single("main")?.detach(); - - let graph = Graph::from_commit_traversal_tips( - &repo, - [ - Tip::entrypoint(merged_id, None), - Tip::reachable(a_id, None), - Tip::integrated(main_id, None), - ], - &*meta, - but_core::ref_metadata::ProjectMeta::default(), - standard_options(), - )? - .validated()?; - - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰โ–บ:2[0]:merged[๐ŸŒณ] - โ””โ”€โ”€ ยท8a6c109 (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:1[1]:A - โ”‚ โ””โ”€โ”€ ยท62b409a (โŒ‚|1) - โ”‚ โ”œโ”€โ”€ โ–บ:3[2]:anon: - โ”‚ โ”‚ โ””โ”€โ”€ ยท592abec (โŒ‚|1) - โ”‚ โ”‚ โ””โ”€โ”€ โ–บ:0[3]:main - โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿยท965998b (โŒ‚|โœ“|1) - โ”‚ โ””โ”€โ”€ โ–บ:4[2]:B - โ”‚ โ””โ”€โ”€ ยทf16dddf (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:0: (main) - โ””โ”€โ”€ โ–บ:5[1]:C - โ””โ”€โ”€ ยท7ed512a (โŒ‚|1) - โ”œโ”€โ”€ โ–บ:6[2]:anon: - โ”‚ โ””โ”€โ”€ ยท35ee481 (โŒ‚|1) - โ”‚ โ””โ”€โ”€ โ†’:0: (main) - โ””โ”€โ”€ โ–บ:7[2]:D - โ””โ”€โ”€ ยทecb1877 (โŒ‚|1) - โ””โ”€โ”€ โ†’:0: (main) - -"#]] - ); - - let (main_seg, main) = graph - .segment_and_commit_by_ref_name(ref_name("refs/heads/main")?.as_ref()) - .expect("main segment"); - assert!( - main.flags.contains(CommitFlags::Integrated), - "integrated tips should be queued before reachable tips even if the caller provides them last" - ); - assert_eq!( - main_seg.id.index(), - 0, - "schedule first, hence the first node" - ); - - Ok(()) -} - -#[test] -fn relation_between_handles_identity_and_disjoint_segments() -> anyhow::Result<()> { - let (repo, meta) = read_only_in_memory_scenario("four-diamond")?; - let mut graph = Graph::from_head( - &repo, - &*meta, - but_core::ref_metadata::ProjectMeta::default(), - standard_options(), - )? - .validated()?; - - let main = segment_id_by_ref_name(&graph, "refs/heads/main")?; - let a = segment_id_by_ref_name(&graph, "refs/heads/A")?; - assert_eq!( - graph.relation_between(main, main), - SegmentRelation::Identity - ); - - let orphan = graph.insert_segment(Segment { - generation: 0, - ..Default::default() - }); - assert_eq!( - graph.relation_between(main, orphan), - SegmentRelation::Disjoint - ); - assert_eq!(graph.find_merge_base_octopus([main, orphan]), None); - assert_eq!(graph.find_merge_base_octopus([main, orphan, a]), None); - - Ok(()) -} - -#[test] -fn merge_base_apis_can_resolve_segments_by_first_commit_id() -> anyhow::Result<()> { - let (repo, meta) = read_only_in_memory_scenario("four-diamond")?; - let graph = Graph::from_head( - &repo, - &*meta, - but_core::ref_metadata::ProjectMeta::default(), - standard_options(), - )? - .validated()?; - - let merged = segment_id_by_ref_name(&graph, "refs/heads/merged")?; - let a = segment_id_by_ref_name(&graph, "refs/heads/A")?; - let c = segment_id_by_ref_name(&graph, "refs/heads/C")?; - let main = segment_id_by_ref_name(&graph, "refs/heads/main")?; - - let merged_id = graph[merged].tip().expect("commit"); - let a_id = graph[a].tip().expect("commit"); - let c_id = graph[c].tip().expect("commit"); - let main_id = graph[main].tip().expect("commit"); - - assert_eq!( - graph.relation_between_by_commit_id(a_id, merged_id)?, - SegmentRelation::Ancestor - ); - assert_eq!( - graph.find_merge_base_by_commit_id(merged_id, a_id)?, - Some(a_id) - ); - assert_eq!( - graph.find_merge_base_octopus_by_commit_id([a_id, c_id, merged_id])?, - Some(main_id) - ); - - assert!( - graph - .find_merge_base_by_commit_id(repo.object_hash().null(), main_id) - .is_err() - ); - - Ok(()) -} - -fn segment_id_by_ref_name(graph: &Graph, name: &str) -> anyhow::Result { - let full_name = ref_name(name)?; - graph - .segment_by_ref_name(full_name.as_ref()) - .map(|s| s.id) - .ok_or_else(|| anyhow::anyhow!("missing segment for {name}")) -} - -fn ref_name(name: &str) -> anyhow::Result { - name.try_into() - .with_context(|| format!("invalid ref name {name}")) -} - -fn ids_by_revs(repo: &gix::Repository, revs: &[&str]) -> anyhow::Result> { - revs.iter() - .map(|rev| Ok(repo.rev_parse_single(*rev)?.detach())) - .collect() -} diff --git a/crates/but-graph/tests/graph/vis.rs b/crates/but-graph/tests/graph/vis.rs deleted file mode 100644 index 35f197ac47a..00000000000 --- a/crates/but-graph/tests/graph/vis.rs +++ /dev/null @@ -1,177 +0,0 @@ -//! Tests for visualizing the graph data structure. - -use std::str::FromStr; - -use but_core::ref_metadata; -use but_graph::{Commit, CommitFlags, Graph, RefInfo, Segment, SegmentIndex, SegmentMetadata}; -use but_testsupport::graph_tree; -use gix::ObjectId; - -/// 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, -/// with *some* logic applied. -#[test] -fn post_graph_traversal() -> anyhow::Result<()> { - let mut graph = Graph::default(); - let init_commit_id = id("feba"); - // 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.into(), - ref_info: Some(RefInfo { - ref_name: "refs/heads/main".try_into()?, - commit_id: None, - worktree: None, - }), - remote_tracking_ref_name: Some("refs/remotes/origin/main".try_into()?), - metadata: Some(SegmentMetadata::Workspace(ref_metadata::Workspace::new( - Default::default(), - vec![], - ref_metadata::ProjectMeta::default(), - ))), - ..Default::default() - }; - - let local_target = graph.insert_segment_set_entrypoint(local_target); - graph.connect_new_segment( - local_target, - None, - // A newly created branch which appears at the workspace base. - Segment { - id: 1.into(), - ref_info: Some(RefInfo { - ref_name: "refs/heads/new-stack".try_into()?, - commit_id: None, - worktree: None, - }), - ..Default::default() - }, - 0, - None, - 0, - ); - - let remote_to_local_target = Segment { - id: 2.into(), - ref_info: Some(RefInfo { - ref_name: "refs/remotes/origin/main".try_into()?, - commit_id: None, - worktree: None, - }), - commits: vec![commit(id("c"), Some(init_commit_id), CommitFlags::empty())], - ..Default::default() - }; - graph.connect_new_segment(local_target, None, remote_to_local_target, 0, None, 0); - - let branch = Segment { - id: 3.into(), - generation: 2, - ref_info: Some(RefInfo { - ref_name: "refs/heads/A".try_into()?, - commit_id: None, - worktree: None, - }), - remote_tracking_ref_name: Some("refs/remotes/origin/A".try_into()?), - sibling_segment_id: None, - remote_tracking_branch_segment_id: Some(SegmentIndex::from(1)), - commits: vec![ - commit(id("a"), Some(init_commit_id), CommitFlags::InWorkspace), - commit(init_commit_id, None, CommitFlags::InWorkspace), - ], - metadata: None, - }; - let branch = graph.connect_new_segment(local_target, None, branch, 0, None, 0); - - let remote_to_root_branch = Segment { - id: 4.into(), - ref_info: Some(RefInfo { - ref_name: "refs/remotes/origin/A".try_into()?, - commit_id: None, - worktree: None, - }), - commits: vec![ - commit(id("b"), 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 - // may need to include commits from connected segments, depending on logical constraints. - ], - ..Default::default() - }; - graph.connect_new_segment(branch, 1, remote_to_root_branch, 0, None, 0); - - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“•โ–บโ–บโ–บ:0[0]:main <> origin/main - โ”œโ”€โ”€ โ–บ:1[0]:new-stack - โ”œโ”€โ”€ โ–บ:2[0]:origin/main - โ”‚ โ””โ”€โ”€ โœ‚๐ŸŸฃccccccc - โ””โ”€โ”€ โ–บ:3[2]:A <> origin/A โ†’:1: - โ”œโ”€โ”€ ๐ŸŸฃaaaaaaa (๐Ÿ˜) - โ””โ”€โ”€ ๐ŸŸฃfebafeb (๐Ÿ˜) - โ””โ”€โ”€ โ–บ:4[0]:origin/A - โ””โ”€โ”€ โœ‚๐ŸŸฃbbbbbbb - -"#]] - ); - - Ok(()) -} - -#[test] -fn detached_head() { - let mut graph = Graph::default(); - graph.insert_segment_set_entrypoint(Segment { - commits: vec![commit(id("a"), None, CommitFlags::empty())], - ..Default::default() - }); - snapbox::assert_data_eq!( - graph_tree(&graph).to_string(), - snapbox::str![[r#" - -โ””โ”€โ”€ โ–บ:0[0]:anon: - โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ๐ŸŸฃaaaaaaa - -"#]] - ); -} - -fn id(hex: &str) -> ObjectId { - let hash_len = gix::hash::Kind::Sha1.len_in_hex(); - if hex.len() != hash_len { - ObjectId::from_str( - &std::iter::repeat_n(hex, hash_len / hex.len()) - .collect::>() - .join(""), - ) - } else { - ObjectId::from_str(hex) - } - .unwrap() -} - -fn commit( - id: ObjectId, - parent_ids: impl IntoIterator, - flags: CommitFlags, -) -> Commit { - Commit { - id, - parent_ids: parent_ids.into_iter().collect(), - refs: Vec::new(), - flags, - } -} - -#[test] -fn unborn_head() { - snapbox::assert_data_eq!( - graph_tree(&Graph::default()).to_string(), - snapbox::str![[r#" - - -"#]] - ); -} diff --git a/crates/but-graph/tests/graph/workspace/legacy.rs b/crates/but-graph/tests/graph/workspace/legacy.rs deleted file mode 100644 index f4b39fcacc1..00000000000 --- a/crates/but-graph/tests/graph/workspace/legacy.rs +++ /dev/null @@ -1,46 +0,0 @@ -use but_graph::Graph; - -use super::project_meta; -use crate::init::utils::{ - add_workspace_with_target, add_workspace_without_target, read_only_in_memory_scenario, - standard_options, -}; - -#[test] -fn distinguishes_target_base_from_ref_tip() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/local-target-and-stack")?; - let base_id = repo.rev_parse_single(":/M2")?.detach(); - let target_tip_id = repo.rev_parse_single("origin/main")?.detach(); - - add_workspace_with_target(&mut meta, base_id); - - let ws = Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? - .validated()? - .into_workspace()?; - - assert_eq!(ws.target_base_commit_id(), Some(base_id)); - assert_eq!(ws.target_ref_tip_commit_id(), Some(target_tip_id)); - assert_eq!( - ws.legacy_target_ref_name().map(ToString::to_string), - Some("refs/remotes/origin/main".to_string()) - ); - - Ok(()) -} - -#[test] -fn target_helpers_return_none_without_target() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("ws/no-target-without-ws-commit")?; - - add_workspace_without_target(&mut meta); - - let ws = Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? - .validated()? - .into_workspace()?; - - assert_eq!(ws.target_base_commit_id(), None); - assert_eq!(ws.target_ref_tip_commit_id(), None); - assert_eq!(ws.legacy_target_ref_name(), None); - - Ok(()) -} From adfce06e38390a3cb2365072012e508443fee9f1 Mon Sep 17 00:00:00 2001 From: Mattias Granlund Date: Sun, 12 Jul 2026 11:30:07 +0300 Subject: [PATCH 09/11] Flag workspaces by reachability, not traversal order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated / InWorkspace / NotInRemote used to be marked in-flight during the walk, so results depended on traversal order and per-seed stop points. Edit previews (the write-through seam) have always computed them post-hoc. This unifies on the seam's semantics: - flags sweep post-hoc from the declared seeds over walk-CONNECTED edges; the walk's in-flight flags remain internal steering. Integrated is thereby fully derived โ€” reachability from a target tip, reflexively including the tip itself - proven with an A/B flag census before the flip (98 divergences, 15 unique cases, 3 patterns, all walk-order artifacts); the census harness retired with the old pipeline Five snapshots re-record, each derived reachability replacing a walk-order artifact (enumerated in the PR description): - an extra target's own commit shows integrated even where the workspace partition reached it first โ€” a target tip is in its own history - early-stopped integrated traversals no longer under-mark ancestry that is connected-reachable from target tips - knock-on: some chain anchors re-categorize spliced -> empty under the new flags (layout trailer only) Kept at the top of the stack on purpose: it's the one commit with standalone product semantics, and it reverts cleanly. --- crates/but-graph/src/commit_graph.rs | 32 +++++++++++++++++++ .../tests/graph/walk/with_workspace.rs | 22 ++++++------- .../workspace/branch/create_reference.rs | 4 --- 3 files changed, 43 insertions(+), 15 deletions(-) diff --git a/crates/but-graph/src/commit_graph.rs b/crates/but-graph/src/commit_graph.rs index 9036063aa57..750e7367352 100644 --- a/crates/but-graph/src/commit_graph.rs +++ b/crates/but-graph/src/commit_graph.rs @@ -197,6 +197,7 @@ impl CommitGraph { )?; let mut outcome = Self::from_walk_outcome(walked); outcome.extra_target = extra_target; + outcome.apply_posthoc_flags(); Ok(outcome) } @@ -220,6 +221,7 @@ impl CommitGraph { let mut outcome = Self::from_walk_outcome(walked); outcome.explicit_seeds = true; outcome.extra_target = extra_target; + outcome.apply_posthoc_flags(); Ok(outcome) } @@ -247,6 +249,36 @@ impl CommitGraph { pub fn entrypoint_ref(&self) -> Option<&gix::refs::FullName> { self.entrypoint_ref.as_ref() } + + /// Recompute the three workspace flags from the carried seeds: mark everything reachable + /// over the connected arena (the same sweeps the write-through seam runs). The flags the + /// walk set while traversing only steered the walk โ€” these sweeps produce the + /// authoritative values. + #[tracing::instrument(level = "trace", skip_all)] + pub(crate) fn apply_posthoc_flags(&mut self) { + use crate::CommitFlags; + let mut integrated = Vec::new(); + let mut ws = Vec::new(); + let mut not_in_remote = Vec::new(); + for s in &self.seeds { + if s.role.is_integrated() { + integrated.push(s.id); + } else { + not_in_remote.push(s.id); + } + if matches!(s.role, crate::walk::SeedRole::Workspace) { + ws.push(s.id); + } + } + integrated.extend(self.extra_target); + // A fresh walk's cut edges stay cut: the sweep follows only the parents the + // traversal connected (the seam reconciles all edges before its sweeps, so + // both views coincide there). + self.set_flag_on_ancestors(CommitFlags::Integrated, integrated); + self.set_flag_on_ancestors(CommitFlags::InWorkspace, ws); + self.set_flag_on_ancestors(CommitFlags::NotInRemote, not_in_remote); + } + /// The worktrees referenced by any commit's refs. pub(crate) fn ref_worktrees(&self) -> impl Iterator { self.nodes diff --git a/crates/but-graph/tests/graph/walk/with_workspace.rs b/crates/but-graph/tests/graph/walk/with_workspace.rs index a4e579a5f3f..b8850c28423 100644 --- a/crates/but-graph/tests/graph/walk/with_workspace.rs +++ b/crates/but-graph/tests/graph/walk/with_workspace.rs @@ -1285,7 +1285,7 @@ fn stack_configuration_is_respected_if_one_of_them_is_an_entrypoint() -> anyhow: snapbox::assert_data_eq!( graph_dag(&ws), snapbox::str![[r#" -* ๐Ÿ‘‰๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) โ–บA, โ–บB, โ–บmain +* ๐Ÿ‘‰๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บA, โ–บB, โ–บmain layout: empty chain anchors: fafd9d0 fafd9d0 "#]] @@ -1325,7 +1325,7 @@ layout: snapbox::assert_data_eq!( graph_dag(&ws), snapbox::str![[r#" -* ๐Ÿ‘‰๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) โ–บA, โ–บB, โ–บmain +* ๐Ÿ‘‰๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บA, โ–บB, โ–บmain layout: empty chain anchors: fafd9d0 fafd9d0 "#]] @@ -1354,7 +1354,7 @@ layout: snapbox::assert_data_eq!( graph_dag(&ws), snapbox::str![[r#" -* ๐Ÿ‘‰๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) โ–บA, โ–บB, โ–บmain +* ๐Ÿ‘‰๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บA, โ–บB, โ–บmain layout: empty chain anchors: fafd9d0 fafd9d0 "#]] @@ -2924,9 +2924,9 @@ layout: * ยท09c6e08 (โŒ‚|โœ“) * ยท7b9f260 (โŒ‚|โœ“) โ”œโ”€โ•ฎ -โ”‚ โ”‚ * ยท4077353 (โŒ‚|๐Ÿ˜) -โ”‚ โ”‚ * ยท6b1a13b (โŒ‚|๐Ÿ˜) โ–บB -โ”‚ โ”‚ * ยท03ad472 (โŒ‚|๐Ÿ˜) +โ”‚ โ”‚ * ยท4077353 (โŒ‚|๐Ÿ˜|โœ“) +โ”‚ โ”‚ * ยท6b1a13b (โŒ‚|๐Ÿ˜|โœ“) โ–บB +โ”‚ โ”‚ * ยท03ad472 (โŒ‚|๐Ÿ˜|โœ“) โ”‚ โ”œโ”€โ•ฏ โ”‚ * ยท79bbb29 (โŒ‚|๐Ÿ˜|โœ“) โ–บA โ”‚ * ยทfc98174 (โŒ‚|๐Ÿ˜|โœ“) @@ -6181,10 +6181,10 @@ fn two_branches_one_advanced_two_parent_ws_commit_diverged_ttb() -> anyhow::Resu โ”œโ”€โ•ฎ * โ”‚ ยทcbc6713 (โŒ‚|๐Ÿ˜) โ–บadvanced-lane โ”œโ”€โ•ฏ -* ๐Ÿ‘‰๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜) โ–บlane, โ–บmain <> origin/main +* ๐Ÿ‘‰๐Ÿยทfafd9d0 (โŒ‚|๐Ÿ˜|โœ“) โ–บlane, โ–บmain <> origin/main * ๐Ÿ๐ŸŸฃda83717 (โœ“) โ–บorigin/main layout: - spliced chain anchors: fafd9d0^ cbc6713^ + empty chain anchors: fafd9d0 cbc6713^ "#]] ); snapbox::assert_data_eq!( @@ -6248,7 +6248,7 @@ layout: * ๐Ÿ๐ŸŸฃda83717 (โœ“) โ–บorigin/main layout: materialized parents: 873d056: cbc6713 fafd9d0 - spliced chain anchors: fafd9d0^ cbc6713^ + empty chain anchors: fafd9d0^ cbc6713^ "#]] ); @@ -6315,7 +6315,7 @@ fn advanced_workspace_ref() -> anyhow::Result<()> { * ยทbce0c5e (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บorigin/main <> origin/main * ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“) layout: - spliced chain anchors: 6fdab32^ 8a352d5^ + empty chain anchors: 6fdab32^ 8a352d5^ "#]] ); @@ -6366,7 +6366,7 @@ layout: * ยทbce0c5e (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บorigin/main <> origin/main * ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“) layout: - spliced chain anchors: 6fdab32^ 8a352d5^ + empty chain anchors: 6fdab32^ 8a352d5^ "#]] ); diff --git a/crates/but-workspace/tests/workspace/branch/create_reference.rs b/crates/but-workspace/tests/workspace/branch/create_reference.rs index 1f8a9bac82d..f1c5d5f946d 100644 --- a/crates/but-workspace/tests/workspace/branch/create_reference.rs +++ b/crates/but-workspace/tests/workspace/branch/create_reference.rs @@ -1800,10 +1800,6 @@ Single commit, target, no ws commit, but ws-reference graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 -โ””โ”€โ”€ โ‰ก:main <> origin/mainโ‡ก1โ‡ฃ1 - โ””โ”€โ”€ :main <> origin/mainโ‡ก1โ‡ฃ1 - โ”œโ”€โ”€ ๐ŸŸฃ3183e43 (โœ“) - โ””โ”€โ”€ โœ‚๏ธยทbce0c5e (๐Ÿ˜๏ธ) "#]] ); From 2ef5c6251f122c8a41cd37d3bb62fe889b7342d0 Mon Sep 17 00:00:00 2001 From: Mattias Granlund Date: Sat, 18 Jul 2026 10:21:23 +0200 Subject: [PATCH 10/11] refactor: harden the rebase editor and rebuild operations on the segment graph One idea carried through every layer: the workspace has one authority, the segment graph derived from its facts, and everything else - the display, the operations, the rebase editor - reads it. Data flows one way: facts -> segment graph -> display; nothing writes a picture back. The rebase editor (but-rebase): - Mutations are named intents (remove_commit, detach, move_reference, reparent, ...) instead of ordered low-level graph surgery; callers state what they mean and the editor owns the mechanics. - The editable arena is bounded to the workspace's own commits; history the walk merely traversed is a fixed anchor. Squash/reorder on long histories stops re-creating ancient commits (one real-repo squash: 7,655 commits touched -> 95) and a class of resolved-conflict failures is gone. - Well-formedness violations are hard errors in release builds. - The editor frames its stacks FROM the segment graph: graph_workspace consumes it directly and the private component-based partitioner is deleted, so there is exactly one stack-partition authority (with a deterministic head fallback for anonymous stacks). One Workspace, one stored derivation (but-graph): - The Workspace type is unified: facts (commit graph, metadata, frame, context) plus exactly one stored derivation, the segment graph (SegmentGraph / SegmentStack / Segment). Claiming runs once, at construction; the old display-stack memo is gone. - Segment nodes store their claimed commit ids, so extent questions are reads, not walks; the boundary/first_parent_range machinery is deleted. (Ruling pinned along the way: boundary != base - a sibling claim can cut an extent where the walk severed, so the walk stop derives from the node's own last commit.) - The display is materialized per call from the segment graph (display_stacks()); pruning archived, integrated, and out-of-cone branches is view policy. The out-of-cone prune also fixes a leak where old integrated branches outside the workspace stayed visible. - reconcile_projected_stacks is deleted: the projection is never written back into metadata. Operations read the graph, and their signatures say so (but-workspace): - Every branch operation (apply, unapply, create/remove reference, move) takes the same quartet: the subject ref, &Workspace read-only, &gix::Repository, and &mut impl RefMetadata as the only mutable input, plus destructured Options. - New state comes back as a value (Option / an outcome) and the caller adopts it. Helpers take narrow slices per the Law of Demeter, e.g. get_disconnect_parameters sees one stack and one segment, not the Workspace. - Query vocabulary settled: operational queries are unsuffixed (the default reads the segment graph); display-derived twins are marked _in_display. No operation reads the display; the one deliberate exception is unapply's checkout helpers, which are about what the user sees. Recorded intent over inference: - Workspace merge commits carry a gitbutler-workspace-parents header binding each real parent to its declared stack's tip branch name (names, not ids: ids are runtime-random and made snapshots nondeterministic; names also self-invalidate on rename). The merge writers stamp it; both cherry-pick paths strip it (picks preserve unknown headers verbatim); claiming validates it all-or-nothing and uses it as a fast path, falling back to inference when absent. DAG-ready declaration (inert): - WorkspaceStackBranch.parents can declare in-stack forks/merges, with validation (single tip, acyclic and connected, child-before-parent order) and slot-stable surgery ops; the build-time partition mirrors the edges. Nothing reads the fork shape yet, and toml persistence is parked because old clients would silently misread forks. The path to full DAG stacks is recorded in dag-stacks-plan.md. Verified throughout: full crate rings plus the CLI suite green at every step, three op fuzzers x 450 seeds (local), zero unintended snapshot movement - the few re-recordings are audited hash-only classes plus one intentional reflexive-tip collapse. --- crates/but-action/src/simple.rs | 10 +- crates/but-api/src/branch.rs | 82 +- crates/but-api/src/legacy/stack.rs | 17 +- crates/but-api/src/legacy/virtual_branches.rs | 53 +- crates/but-core/src/commit/mod.rs | 35 + crates/but-core/src/ref_metadata.rs | 399 ++++--- crates/but-core/tests/core/ref_metadata.rs | 564 ++++------ crates/but-debug/src/command/api.rs | 3 +- crates/but-debug/src/command/graph.rs | 6 +- crates/but-debug/src/command/workspace.rs | 9 +- crates/but-graph/src/build/layout.rs | 38 +- crates/but-graph/src/build/materialize.rs | 21 +- crates/but-graph/src/build/mod.rs | 116 +- crates/but-graph/src/build/plan.rs | 43 +- crates/but-graph/src/build/remote_segments.rs | 25 +- crates/but-graph/src/build/segment_data.rs | 260 ++++- crates/but-graph/src/commit_graph.rs | 26 +- crates/but-graph/src/debug.rs | 127 ++- crates/but-graph/src/projection/api/legacy.rs | 2 +- crates/but-graph/src/projection/api/mod.rs | 246 ++--- .../but-graph/src/projection/api/queries.rs | 136 ++- crates/but-graph/src/projection/collect.rs | 367 +++++-- crates/but-graph/src/projection/frame.rs | 47 +- crates/but-graph/src/projection/init.rs | 630 +++++++---- crates/but-graph/src/projection/mod.rs | 383 +++++-- crates/but-graph/src/records.rs | 13 +- crates/but-graph/src/ref_layout.rs | 74 +- crates/but-graph/src/walk/walker.rs | 35 +- .../tests/graph/walk/with_workspace.rs | 112 +- crates/but-hunk-assignment/src/lib.rs | 17 +- crates/but-meta/src/legacy/mod.rs | 8 +- .../tests/meta/ref_metadata_legacy.rs | 41 +- .../src/graph_rebase/cherry_pick.rs | 64 +- crates/but-rebase/src/graph_rebase/commit.rs | 15 + .../but-rebase/src/graph_rebase/creation.rs | 23 +- .../src/graph_rebase/graph_editor.rs | 42 + .../src/graph_rebase/merge_commit_changes.rs | 31 + crates/but-rebase/src/graph_rebase/mutate.rs | 288 ++++- .../but-rebase/src/graph_rebase/positions.rs | 59 +- crates/but-rebase/src/graph_rebase/rebase.rs | 2 +- crates/but-rebase/src/graph_rebase/ref_ops.rs | 17 +- crates/but-rebase/src/graph_rebase/testing.rs | 7 +- .../but-rebase/src/graph_rebase/workspace.rs | 346 +++--- .../tests/rebase/graph_rebase/disconnect.rs | 22 +- .../tests/rebase/graph_rebase/edge.rs | 6 +- .../rebase/graph_rebase/editor_creation.rs | 4 +- .../rebase/graph_rebase/graph_workspace.rs | 155 ++- .../order_commit_selectors_by_parentage.rs | 8 +- .../tests/rebase/graph_rebase/sha256.rs | 2 +- crates/but-testsupport/src/graph.rs | 5 +- crates/but-testsupport/src/in_memory_meta.rs | 1 + crates/but-transaction/src/lib.rs | 12 +- crates/but-workspace/src/branch/apply.rs | 633 ++++++----- .../src/branch/create_reference.rs | 134 ++- .../branch/integrate_branch_upstream/mod.rs | 8 +- .../branch/integrate_branch_upstream/plan.rs | 62 +- crates/but-workspace/src/branch/mod.rs | 108 +- .../but-workspace/src/branch/move_branch.rs | 302 ++++-- .../src/branch/remove_reference.rs | 34 +- crates/but-workspace/src/branch/unapply.rs | 529 ++++------ crates/but-workspace/src/branch_details.rs | 9 +- .../src/commit/discard_commit.rs | 14 +- crates/but-workspace/src/commit/mod.rs | 399 ++++--- .../but-workspace/src/commit/move_commit.rs | 4 +- .../src/commit/squash_commits.rs | 32 +- .../but-workspace/src/graph_manipulation.rs | 21 +- crates/but-workspace/src/legacy/push.rs | 2 +- crates/but-workspace/src/legacy/ui.rs | 9 - crates/but-workspace/src/ref_info.rs | 2 +- crates/but-workspace/src/ui/diff.rs | 3 +- .../but-workspace/src/upstream_integration.rs | 111 +- crates/but-workspace/src/workspace.rs | 6 +- .../ws-ref-ws-commit-chain-below-bound.sh | 32 + ...ws-ref-ws-commit-empties-between-owners.sh | 37 + .../ws-ref-ws-commit-empty-at-own-leg-base.sh | 22 + ...-ref-ws-commit-empty-at-unpulled-target.sh | 30 + ...f-ws-commit-empty-inside-anonymous-lane.sh | 38 + ...-commit-region-boundary-mid-outside-run.sh | 38 + ...-ref-ws-commit-two-stacks-shared-parent.sh | 26 + .../tests/workspace/branch/apply_unapply.rs | 645 +++++++----- .../workspace/branch/create_reference.rs | 273 +++-- .../tests/workspace/branch/move_branch.rs | 994 +++++++++++++++++- .../tests/workspace/commit/mod.rs | 23 +- .../tests/workspace/commit/move_commit.rs | 14 +- .../workspace/commit/uncommit_changes.rs | 5 +- crates/but-workspace/tests/workspace/graph.rs | 246 +++-- .../ref_info/with_workspace_commit/mod.rs | 12 + crates/but/src/command/branch/update.rs | 2 +- crates/but/src/command/config.rs | 4 +- crates/but/src/command/legacy/squash2.rs | 2 +- crates/but/src/command/legacy/status/mod.rs | 2 +- .../command/legacy/status/tui/app/rub_mode.rs | 7 +- crates/but/src/id/mod.rs | 10 +- crates/but/src/id/parser.rs | 7 +- crates/but/src/utils/metrics.rs | 3 +- crates/but/src/utils/rejection.rs | 6 +- crates/but/tests/but/command/branch/apply.rs | 12 +- .../but/tests/but/command/branch/unapply.rs | 10 +- crates/but/tests/but/command/move.rs | 1 + crates/but/tests/but/command/pull.rs | 1 + crates/but/tests/but/command/status.rs | 3 +- .../tests/branch-actions/driverless.rs | 1 + .../virtual_branches/set_base_branch.rs | 2 +- crates/gitbutler-edit-mode/src/lib.rs | 8 +- crates/gitbutler-edit-mode/tests/edit_mode.rs | 3 +- crates/gitbutler-operating-modes/src/lib.rs | 2 +- crates/gitbutler-stack/tests/stack.rs | 2 + 107 files changed, 6652 insertions(+), 3347 deletions(-) create mode 100755 crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-chain-below-bound.sh create mode 100755 crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-empties-between-owners.sh create mode 100755 crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-empty-at-own-leg-base.sh create mode 100755 crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-empty-at-unpulled-target.sh create mode 100755 crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-empty-inside-anonymous-lane.sh create mode 100755 crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-region-boundary-mid-outside-run.sh create mode 100755 crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-two-stacks-shared-parent.sh diff --git a/crates/but-action/src/simple.rs b/crates/but-action/src/simple.rs index 89d6bd4d860..50a827a4204 100644 --- a/crates/but-action/src/simple.rs +++ b/crates/but-action/src/simple.rs @@ -174,9 +174,10 @@ fn stacks_creating_if_none( |_| StackId::generate(), None, )?; - *ws = new_ws.into_owned(); - let stack = ws - .stacks + // The display boundary: the context cache holds the pruned display workspace. + ws.adopt(new_ws); + let display_stacks = ws.display_stacks()?; + let stack = display_stacks .iter() .find(|stack| stack.ref_name() == Some(branch_name.as_ref())) .context("BUG: need to find stack that was just created")?; @@ -194,7 +195,8 @@ fn stacks_creating_if_none( /// Stacks without an ID or reference name are skipped because the action needs both values to map /// assignments to a branch and report the resulting update. fn stack_info(ws: &but_graph::Workspace) -> Vec { - ws.stacks + ws.display_stacks() + .expect("displayable") .iter() .filter_map(|stack| { let id = stack.id?; diff --git a/crates/but-api/src/branch.rs b/crates/but-api/src/branch.rs index 8cae1fa24d1..9377caef54d 100644 --- a/crates/but-api/src/branch.rs +++ b/crates/but-api/src/branch.rs @@ -16,7 +16,7 @@ use but_core::{ use but_ctx::Context; use but_error::bail_precondition; use but_oplog::legacy::{OperationKind, SnapshotDetails, Trailer}; -use but_rebase::graph_rebase::{Editor, SuccessfulRebase, mutate::InsertSide}; +use but_rebase::graph_rebase::{Editor, mutate::InsertSide}; use but_workspace::branch::{ BranchIntegrationStrategy, InitialBranchIntegration, OnWorkspaceMergeConflict, apply::{WorkspaceMerge, WorkspaceReferenceNaming}, @@ -638,7 +638,7 @@ pub fn apply_only_with_perm( let (repo, mut ws, _db) = ctx.workspace_mut_and_db_with_perm(perm)?; let out = but_workspace::branch::apply( existing_branch, - ws.clone(), + &ws, &repo, &mut meta, // NOTE: Options can later be passed as parameter, or we have a separate function for that. @@ -653,7 +653,8 @@ pub fn apply_only_with_perm( )?; if out.status.persisted_mutation() { - *ws = out.workspace.clone(); + // The display boundary: the context cache holds the pruned display workspace. + *ws = out.display_workspace()?; } Ok(out) } @@ -811,9 +812,10 @@ pub fn branch_create_with_perm( snapshot.commit(ctx, perm).ok(); } + // The display boundary: the state payload and the context cache read the pruned display. + ws.adopt(new_ws); let workspace = - WorkspaceState::from_workspace_with_db(&new_ws, &mut meta, &repo, BTreeMap::new(), &db)?; - *ws = new_ws.into_owned(); + WorkspaceState::from_workspace_with_db(&ws, &mut meta, &repo, BTreeMap::new(), &db)?; drop(ws); drop(repo); drop(db); @@ -890,7 +892,7 @@ pub fn branch_remove_with_perm( .iter() .position(|s| s.ref_name() == Some(ref_name.as_ref())) .expect("segment we just matched by ref name"); - let is_empty = stack.segments[idx].commits.is_empty(); + let is_empty = stack.segments[idx].tip().is_none(); let below = stack.segments[idx + 1..] .iter() .find_map(|s| s.ref_name().map(|r| r.to_owned())); @@ -952,9 +954,7 @@ pub fn branch_remove_with_perm( }, )?; let changed = new_ws.is_some(); - if let Some(new_ws) = new_ws { - *ws = new_ws; - } + ws.adopt(new_ws); changed }; @@ -1566,24 +1566,12 @@ pub fn move_branch_with_perm( let mut meta = ctx.meta()?; let (repo, mut ws, db) = ctx.workspace_mut_and_db_with_perm(perm)?; let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; - let but_workspace::branch::move_branch::Outcome { - rebase, - ws_meta, - new_tip, - branch_stack_order, - } = but_workspace::branch::move_branch(editor, &ws, subject_branch, target_branch)?; + let outcome = + but_workspace::branch::move_branch(editor, &ws, subject_branch, target_branch)?; + let new_tip = outcome.new_tip.clone(); let result = MoveBranchResult { - workspace: branch_workspace_from_rebase( - &mut ws, - rebase, - ws_meta, - new_tip.as_ref(), - branch_stack_order.as_deref(), - &repo, - dry_run, - &db, - )?, + workspace: branch_workspace_from_rebase(&mut ws, outcome, &repo, dry_run, &db)?, }; Ok((result, new_tip)) }, @@ -1647,14 +1635,11 @@ pub fn tear_off_branch_with_perm( let mut meta = ctx.meta()?; let (repo, mut ws, db) = ctx.workspace_mut_and_db_with_perm(perm)?; let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; - let but_workspace::branch::move_branch::Outcome { - rebase, ws_meta, .. - } = but_workspace::branch::tear_off_branch(editor, &ws, subject_branch, None)?; + let outcome = + but_workspace::branch::tear_off_branch(editor, &ws, subject_branch, None)?; Ok(MoveBranchResult { - workspace: branch_workspace_from_rebase( - &mut ws, rebase, ws_meta, None, None, &repo, dry_run, &db, - )?, + workspace: branch_workspace_from_rebase(&mut ws, outcome, &repo, dry_run, &db)?, }) }, ) @@ -1687,48 +1672,37 @@ where result } -#[allow(clippy::too_many_arguments)] fn branch_workspace_from_rebase( workspace: &mut but_graph::Workspace, - mut rebase: SuccessfulRebase<'_, M>, - ws_meta: Option, - new_tip: Option<&gix::refs::FullName>, - branch_stack_order: Option<&[gix::refs::FullName]>, + outcome: but_workspace::branch::move_branch::Outcome<'_, M>, repo: &gix::Repository, dry_run: DryRun, db: &but_db::DbHandle, ) -> anyhow::Result { if dry_run.into() { - let entrypoint = new_tip + let mut rebase = outcome.rebase; + let entrypoint = outcome + .new_tip .map(|new_tip| -> anyhow::Result<_> { - Ok((rebase.reference_target(new_tip.as_ref())?, new_tip.clone())) + Ok((rebase.reference_target(new_tip.as_ref())?, new_tip)) }) .transpose()?; let replaced_commits = rebase.history.commit_mappings(); - let overlay = - rebase.rebase_overlay_with_workspace_overrides(entrypoint, branch_stack_order)?; + let overlay = rebase.rebase_overlay_with_workspace_overrides( + entrypoint, + outcome.branch_stack_order.as_deref(), + )?; let (repo, meta) = rebase.repo_and_meta_mut(); let preview = workspace.redo(repo, meta, overlay)?; return WorkspaceState::from_workspace_with_db(&preview, meta, repo, replaced_commits, db); } - let materialized = rebase.materialize()?; - if let Some(order) = branch_stack_order { - materialized.meta.set_branch_stack_order(order)?; - } - workspace.refresh_from_commit_graph(materialized.arena().clone(), repo, materialized.meta)?; - if let Some((ws_meta, ref_name)) = ws_meta.zip(workspace.ref_name()) { - let mut md = materialized.meta.workspace(ref_name)?; - *md = ws_meta; - md.set_project_meta(workspace.project_meta().clone()); - materialized.meta.set_workspace(&md)?; - } - + let applied = outcome.apply(workspace, repo)?; WorkspaceState::from_workspace_with_db( workspace, - materialized.meta, + applied.meta, repo, - materialized.history.commit_mappings(), + applied.commit_mappings, db, ) } diff --git a/crates/but-api/src/legacy/stack.rs b/crates/but-api/src/legacy/stack.rs index 33389c95421..fb7476bdd5a 100644 --- a/crates/but-api/src/legacy/stack.rs +++ b/crates/but-api/src/legacy/stack.rs @@ -145,11 +145,11 @@ pub fn create_reference_with_perm( snapshot.commit(ctx, perm).ok(); } - let stack_id = new_ws + // The display boundary: the context cache holds the pruned display workspace. + ws.adopt(new_ws); + let stack_id = ws .find_segment_and_stack_by_refname(new_ref.as_ref()) .and_then(|(stack, _)| stack.id); - - *ws = new_ws.into_owned(); Ok((stack_id, new_ref)) } @@ -175,7 +175,9 @@ pub fn create_branch( .ok(); let (repo, mut ws, _) = ctx.workspace_mut_and_db_with_perm(guard.write_permission())?; - let stack = ws.try_find_stack_by_id(stack_id)?; + let stacks = ws.display_stacks()?; + let stack = but_graph::workspace::find_stack_by_id(&stacks, stack_id) + .with_context(|| format!("Couldn't find stack with id {stack_id:?} in workspace"))?; if request.preceding_head.is_some() { return Err(anyhow!( "BUG: cannot have preceding head name set - let's use the new API instead" @@ -217,7 +219,8 @@ pub fn create_branch( None, // order - not used for dependent branches )?; - *ws = new_ws.into_owned(); + // The display boundary: the context cache holds the pruned display workspace. + ws.adopt(new_ws); Ok(()) } @@ -246,9 +249,7 @@ pub fn remove_branch_only( }, )?; - if let Some(new_ws) = new_ws { - *ws = new_ws; - } + ws.adopt(new_ws); Ok(()) } diff --git a/crates/but-api/src/legacy/virtual_branches.rs b/crates/but-api/src/legacy/virtual_branches.rs index 7ab8dc98960..99b476476e5 100644 --- a/crates/but-api/src/legacy/virtual_branches.rs +++ b/crates/but-api/src/legacy/virtual_branches.rs @@ -5,7 +5,7 @@ use bstr::ByteSlice; use but_api_macros::but_api; use but_core::{ DiffSpec, RefMetadata, - ref_metadata::{StackId, StackKind, WorkspaceStack}, + ref_metadata::{StackId, StackKind}, sync::{RepoExclusive, RepoShared}, }; use but_ctx::Context; @@ -62,10 +62,13 @@ pub fn create_virtual_branch( branch.order, )?; - let (stack_idx, segment_idx) = new_ws - .find_segment_owner_indexes_by_refname(new_ref.as_ref()) - .context("BUG: didn't find a stack that was just created")?; - let stack = &new_ws.stacks[stack_idx]; + // The display boundary: the entry summary and the context cache read the pruned display. + ws.adopt(new_ws); + let stacks = ws.display_stacks()?; + let (stack_idx, segment_idx) = + but_graph::workspace::find_segment_owner_indexes_by_refname(&stacks, new_ref.as_ref()) + .context("BUG: didn't find a stack that was just created")?; + let stack = &stacks[stack_idx]; let tip = stack.segments[segment_idx] .tip() .unwrap_or(repo.object_hash().null()); @@ -89,7 +92,6 @@ pub fn create_virtual_branch( is_checked_out: false, }; - *ws = new_ws.into_owned(); out }; Ok(stack_entry) @@ -118,7 +120,7 @@ pub fn delete_local_branch( bail_precondition!("Cannot delete a branch that is applied in workspace"); } - if let Some(new_ws) = but_workspace::branch::remove_reference( + let updated = but_workspace::branch::remove_reference( branch_refname.as_ref(), &repo, &ws, @@ -127,9 +129,10 @@ pub fn delete_local_branch( avoid_anonymous_stacks: false, keep_metadata: false, }, - )? { - *ws = new_ws; - } else { + )?; + let removed_from_workspace = updated.is_some(); + ws.adopt(updated); + if !removed_from_workspace { if let Some(reference) = repo.try_find_reference(branch_refname.as_ref())? { let safe_delete = but_core::branch::SafeDelete::new(&repo)?; let outcome = safe_delete.delete_reference(&reference)?; @@ -287,7 +290,10 @@ pub fn update_stack_order_with_perm( meta.set_workspace(&workspace_metadata)?; meta.set_changed_to_necessitate_write(); ws.metadata = Some(updated_metadata); - sort_projected_stacks_like_metadata(&mut ws.stacks, &workspace_metadata.stacks); + // Stack ORDER is stamped from the declared metadata at build time: rebuild the cached + // workspace from the just-persisted truth instead of hand-patching its display. + let project_meta = ws.project_meta().clone(); + ws.refresh_from_head(&_repo, &meta, project_meta)?; } Ok(()) @@ -347,24 +353,6 @@ fn apply_stack_order_updates( .ne(original_stack_ids)) } -fn sort_projected_stacks_like_metadata( - stacks: &mut [but_graph::workspace::Stack], - metadata_stacks: &[WorkspaceStack], -) { - let stack_orders = metadata_stacks - .iter() - .enumerate() - .map(|(order, stack)| (stack.id, order)) - .collect::>(); - - stacks.sort_by_key(|stack| { - stack - .id - .and_then(|stack_id| stack_orders.get(&stack_id).copied()) - .unwrap_or(usize::MAX) - }); -} - #[cfg(test)] mod tests { use but_core::ref_metadata::{ @@ -446,6 +434,7 @@ mod tests { ref_name: gix::refs::FullName::try_from(format!("refs/heads/{name}")) .expect("valid test ref name"), archived: false, + parents: None, }], } } @@ -573,7 +562,8 @@ fn unapply_stack_v3_with_perm( workspace_disposition, }, )?; - *ws = outcome.workspace.into_owned(); + // The display boundary: the context cache holds the pruned display workspace. + ws.adopt(outcome.workspace); // Keeping the workspace merge commit can make legacy reconciliation infer the // removed stack as applied again, so persist the explicit workspace metadata. meta.write_unreconciled()?; @@ -590,7 +580,8 @@ fn stack_branch_names( perm: &mut RepoExclusive, ) -> Result> { let (_repo, ws, _) = ctx.workspace_mut_and_db_with_perm(perm)?; - let Some(stack) = ws.stacks.iter().find(|stack| stack.id == Some(stack_id)) else { + let stacks = ws.display_stacks()?; + let Some(stack) = stacks.iter().find(|stack| stack.id == Some(stack_id)) else { return Err( anyhow!("branch with ID {stack_id} not found").context(but_error::Code::BranchNotFound) ); diff --git a/crates/but-core/src/commit/mod.rs b/crates/but-core/src/commit/mod.rs index 7f71d7cfb9f..e0ed1952e08 100644 --- a/crates/but-core/src/commit/mod.rs +++ b/crates/but-core/src/commit/mod.rs @@ -178,6 +178,41 @@ impl Headers { } } +/// The header field on WORKSPACE merge commits recording, per parent in parent order, +/// the declared stack each edge was merged for, by its TIP branch's full ref name โ€” +/// the writer's intent, so the projection can read the parentโ†”stack binding instead +/// of inferring it. `_` marks a deliberately anonymous parent; entries are separated +/// by a space (illegal inside ref names). A renamed tip no longer resolves, so the +/// binding self-invalidates. Any rewrite that changes parents must strip it (the +/// rebase editor's picks do). +pub const HEADERS_WORKSPACE_PARENTS_FIELD: &str = "gitbutler-workspace-parents"; + +/// Serialize per-parent tip bindings for [`HEADERS_WORKSPACE_PARENTS_FIELD`]. +pub fn encode_workspace_parents(bindings: &[Option]) -> BString { + let mut out = BString::default(); + for (idx, binding) in bindings.iter().enumerate() { + if idx > 0 { + out.push(b' '); + } + match binding { + Some(name) => out.extend_from_slice(name.as_bstr()), + None => out.push(b'_'), + } + } + out +} + +/// Parse [`HEADERS_WORKSPACE_PARENTS_FIELD`]; `None` if any entry is malformed. +pub fn decode_workspace_parents(value: &BStr) -> Option>> { + value + .split(|b| *b == b' ') + .map(|entry| match entry { + b"_" => Some(None), + name => gix::refs::FullName::try_from(name.as_bstr()).ok().map(Some), + }) + .collect() +} + const HEADERS_VERSION_FIELD: &str = "gitbutler-headers-version"; const HEADERS_CHANGE_ID_FIELD: &str = "gitbutler-change-id"; const HEADERS_NEW_CHANGE_ID_FIELD: &str = "change-id"; diff --git a/crates/but-core/src/ref_metadata.rs b/crates/but-core/src/ref_metadata.rs index d30e3f147f2..c69b9c5e149 100644 --- a/crates/but-core/src/ref_metadata.rs +++ b/crates/but-core/src/ref_metadata.rs @@ -65,25 +65,6 @@ pub struct Workspace { push_remote: Option, } -/// A projected workspace stack used to reconcile persisted workspace metadata. -/// -/// This is intentionally smaller than the full workspace projection so metadata -/// code does not depend on graph presentation types and only sees what it needs. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ProjectedWorkspaceStack { - /// Existing stable stack id from projection, if one was already known. - /// - /// `Some(id)` means reconciliation may use that id to match an existing - /// metadata stack, or preserve it when creating metadata for a projected - /// stack that is missing from metadata. - /// - /// `None` means reconciliation should create a new stack id if the projected - /// stack does not match any existing metadata stack. - pub id: Option, - /// Branch names in stack order, from tip toward base. - pub branches: Vec, -} - impl std::fmt::Debug for Workspace { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let Workspace { @@ -149,86 +130,6 @@ impl Workspace { self.push_remote = push_remote; } - /// Add missing metadata for stacks visible in the current workspace projection. - /// - /// This is additive with respect to branch names: projected branches are - /// added to metadata when missing, existing branch metadata is preserved, - /// and branches not present in `projected_stacks` are not removed. - /// - /// Projected stacks are authoritative for grouping. If a projected branch is - /// already in another metadata stack, it is moved to the projected stack. - /// Metadata stacks made empty by such moves are removed. Existing metadata - /// may contain duplicate branch names across stacks; these are tolerated as - /// stale hints. Projected branch names must still be unique. - pub fn reconcile_projected_stacks( - &mut self, - projected_stacks: impl IntoIterator, - mut new_stack_id: impl FnMut(&gix::refs::FullNameRef) -> StackId, - ) -> Result<()> { - let projected_stacks = projected_stacks - .into_iter() - .filter(|stack| !stack.branches.is_empty()) - .collect::>(); - ensure_unique_branch_names( - projected_stacks - .iter() - .flat_map(|stack| stack.branches.iter().map(|branch| branch.as_ref())), - "projected workspace", - )?; - - for ProjectedWorkspaceStack { - id: projected_stack_id, - branches: projected_branches, - } in projected_stacks - { - let owning_stack_idx = projected_stack_id - .and_then(|id| self.stacks.iter().position(|stack| stack.id == id)) - .or_else(|| { - projected_branches.iter().find_map(|branch| { - self.find_owner_indexes_by_name( - branch.as_ref(), - StackKind::AppliedAndUnapplied, - ) - .map(|(stack_idx, _branch_idx)| stack_idx) - }) - }); - - if let Some(stack_idx) = owning_stack_idx { - let mut branches = Vec::new(); - for branch in projected_branches { - branches.push( - remove_branch_from_stacks(&mut self.stacks, stack_idx, branch.as_ref()) - .unwrap_or(WorkspaceStackBranch { - ref_name: branch, - archived: false, - }), - ); - } - let stack = &mut self.stacks[stack_idx]; - branches.extend(std::mem::take(&mut stack.branches)); - stack.branches = branches; - stack.workspacecommit_relation = WorkspaceCommitRelation::Merged; - } else { - let stack_id = projected_stack_id - .unwrap_or_else(|| new_stack_id(projected_branches[0].as_ref())); - self.stacks.push(WorkspaceStack { - id: stack_id, - branches: projected_branches - .into_iter() - .map(|ref_name| WorkspaceStackBranch { - ref_name, - archived: false, - }) - .collect(), - workspacecommit_relation: WorkspaceCommitRelation::Merged, - }); - } - } - self.stacks.retain(|stack| !stack.branches.is_empty()); - - Ok(()) - } - /// Remove the named segment `branch`, which removes the whole stack if it's empty after removing a segment /// of that name. /// Returns `true` if it was removed or `false` if it wasn't found. @@ -240,6 +141,7 @@ impl Workspace { }; let stack = &mut self.stacks[stack_idx]; + stack.splice_branch_edges(segment_idx); stack.branches.remove(segment_idx); if stack.branches.is_empty() { @@ -275,6 +177,7 @@ impl Workspace { self.stacks[stack_idx].workspacecommit_relation = WorkspaceCommitRelation::Outside; } else { // It's a segment in the middle, remove its metadata. + self.stacks[stack_idx].splice_branch_edges(segment_idx); self.stacks[stack_idx].branches.remove(segment_idx); } @@ -311,6 +214,7 @@ impl Workspace { branches: vec![WorkspaceStackBranch { ref_name: branch.to_owned(), archived: false, + parents: None, }], }; let stack_idx = match order.map(|idx| idx.min(self.stacks.len())) { @@ -345,6 +249,7 @@ impl Workspace { WorkspaceStackBranch { ref_name: branch.to_owned(), archived: false, + parents: None, }, ); Some(true) @@ -647,43 +552,6 @@ fn set_or_remove( Ok(()) } -fn ensure_unique_branch_names<'a>( - names: impl IntoIterator, - source: &str, -) -> Result<()> { - let mut seen = Vec::::new(); - for name in names { - if seen.iter().any(|seen| seen.as_ref() == name) { - bail!("Cannot reconcile {source}: branch name '{name}' occurs more than once"); - } - seen.push(name.to_owned()); - } - Ok(()) -} - -fn remove_branch_from_stacks( - stacks: &mut [WorkspaceStack], - preferred_stack_idx: usize, - name: &gix::refs::FullNameRef, -) -> Option { - if let Some(stack) = stacks.get_mut(preferred_stack_idx) - && let Some(branch_idx) = stack - .branches - .iter() - .position(|branch| branch.ref_name.as_ref() == name) - { - return Some(stack.branches.remove(branch_idx)); - } - - stacks.iter_mut().find_map(|stack| { - let branch_idx = stack - .branches - .iter() - .position(|branch| branch.ref_name.as_ref() == name)?; - Some(stack.branches.remove(branch_idx)) - }) -} - /// Determine what kind of stack a query operation is interested in. #[derive(Debug, Clone, Copy)] pub enum StackKind { @@ -1041,11 +909,22 @@ pub struct WorkspaceStackBranch { /// However, they must disappear once the whole stack has been integrated and the workspace has moved past it. /// Note that this flag must be stored with the workspace as it must survive the deletion of a reference. pub archived: bool, + /// The in-stack branches this one rests on โ€” the DAG declaration for graphs + /// INSIDE a stack. `None` = implied by list adjacency (the legacy chain: the + /// next entry, the last entry resting on the stack base). `Some(vec![])` = rests + /// directly on the stack base. Structure is validated by + /// [`WorkspaceStack::validate_structure`]; the branches list stays the declared + /// topological (display) order โ€” every parent is listed BELOW its child. + pub parents: Option>, } impl std::fmt::Debug for WorkspaceStackBranch { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let WorkspaceStackBranch { ref_name, archived } = self; + let WorkspaceStackBranch { + ref_name, + archived, + parents: _, + } = self; f.debug_struct("WorkspaceStackBranch") .field("ref_name", &ref_name.as_bstr()) .field("archived", archived) @@ -1059,6 +938,252 @@ impl WorkspaceStack { self.branches.first().map(|b| &b.ref_name) } + /// Whether any branch declares explicit [`parents`](WorkspaceStackBranch::parents) โ€” + /// the stack is (potentially) a DAG rather than the implied chain. + pub fn has_explicit_parents(&self) -> bool { + self.branches.iter().any(|b| b.parents.is_some()) + } + + /// The declared parent edges as `(child_idx, parent_idx)` pairs, implied chain + /// adjacency resolved (a `None`-parents entry rests on the NEXT entry; the last + /// such entry rests on the stack base and contributes no edge). Explicit parent + /// names that don't resolve in-stack are skipped here โ€” + /// [`validate_structure`](Self::validate_structure) reports them. + pub fn parent_edges(&self) -> Vec<(usize, usize)> { + let index_of = + |name: &gix::refs::FullName| self.branches.iter().position(|b| b.ref_name == *name); + let mut edges = Vec::new(); + for (idx, branch) in self.branches.iter().enumerate() { + match &branch.parents { + None => { + if idx + 1 < self.branches.len() { + edges.push((idx, idx + 1)); + } + } + Some(parents) => { + edges.extend(parents.iter().filter_map(index_of).map(|pidx| (idx, pidx))); + } + } + } + edges + } + + /// Validate the DAG declaration. Stacks without explicit + /// parents keep the legacy chain tolerance and always pass; a declaring stack must + /// uphold: unique branch names, every parent in-stack and not itself, no duplicate + /// parent entries, every edge pointing DOWNWARD in list order (child before parent + /// โ€” which makes the list a linear extension and the graph acyclic), exactly one + /// tip and it is the first entry, and every branch reachable from the tip. + pub fn validate_structure(&self) -> anyhow::Result<()> { + use anyhow::bail; + if !self.has_explicit_parents() { + return Ok(()); + } + let mut seen = std::collections::BTreeSet::new(); + for b in &self.branches { + if !seen.insert(b.ref_name.as_bstr()) { + bail!("Branch '{}' is listed twice in its stack", b.ref_name); + } + } + for (idx, b) in self.branches.iter().enumerate() { + let Some(parents) = &b.parents else { continue }; + let mut parent_seen = std::collections::BTreeSet::new(); + for p in parents { + if *p == b.ref_name { + bail!("Branch '{}' cannot rest on itself", b.ref_name); + } + if !parent_seen.insert(p.as_bstr()) { + bail!("Branch '{}' lists parent '{p}' twice", b.ref_name); + } + let Some(pidx) = self.branches.iter().position(|x| x.ref_name == *p) else { + bail!( + "Branch '{}' rests on '{p}', which is not in its stack", + b.ref_name + ); + }; + if pidx <= idx { + bail!( + "Branch '{}' rests on '{p}', which is declared above it โ€” the list must stay a linear extension (children before parents)", + b.ref_name + ); + } + } + } + let edges = self.parent_edges(); + let is_parent: std::collections::BTreeSet = edges.iter().map(|&(_, p)| p).collect(); + let tips: Vec = (0..self.branches.len()) + .filter(|idx| !is_parent.contains(idx)) + .collect(); + if tips != [0] { + bail!( + "A stack has exactly one tip, its first branch โ€” found tip candidates at positions {tips:?}" + ); + } + let mut reachable = vec![false; self.branches.len()]; + let mut queue = vec![0usize]; + while let Some(idx) = queue.pop() { + if std::mem::replace(&mut reachable[idx], true) { + continue; + } + queue.extend(edges.iter().filter(|&&(c, _)| c == idx).map(|&(_, p)| p)); + } + if let Some(unreached) = reachable.iter().position(|r| !r) { + bail!( + "Branch '{}' is not connected to the stack tip", + self.branches[unreached].ref_name + ); + } + Ok(()) + } + + /// The effective parents of the branch at `idx`: its explicit declaration, or the + /// implied chain adjacency (the next entry; empty at the bottom). + fn effective_parents(&self, idx: usize) -> Vec { + match self.branches.get(idx).and_then(|b| b.parents.as_ref()) { + Some(p) => p.clone(), + None => self + .branches + .get(idx + 1) + .map(|b| vec![b.ref_name.clone()]) + .unwrap_or_default(), + } + } + + /// Rewire edges so removing the branch at `segment_idx` keeps its children resting + /// on its parents (the splice), then let the caller remove the entry. A no-op for + /// plain chains, whose list splice IS the edge splice. + pub(crate) fn splice_branch_edges(&mut self, segment_idx: usize) { + if !self.has_explicit_parents() { + return; + } + let removed_name = self.branches[segment_idx].ref_name.clone(); + let removed_parents = self.effective_parents(segment_idx); + for (idx, b) in self.branches.iter_mut().enumerate() { + if idx == segment_idx { + continue; + } + match &mut b.parents { + Some(parents) => { + if let Some(pos) = parents.iter().position(|p| *p == removed_name) { + parents.remove(pos); + let mut insert_at = pos; + for rp in &removed_parents { + if !parents.contains(rp) { + parents.insert(insert_at, rp.clone()); + insert_at += 1; + } + } + } + } + None => { + // The implied child directly above inherits explicitly: after the + // removal its adjacency would silently point elsewhere. + if idx + 1 == segment_idx { + b.parents = Some(removed_parents.clone()); + } + } + } + } + } + + /// Add `branch` as a FORK resting on `parent`: a second (or later) child of one + /// parent. The new branch is inserted directly above its parent in list order. + pub fn add_fork( + &mut self, + branch: gix::refs::FullName, + parent: &gix::refs::FullNameRef, + ) -> anyhow::Result<()> { + use anyhow::{Context, bail}; + if self + .branches + .iter() + .any(|b| b.ref_name.as_ref() == branch.as_ref()) + { + bail!("Branch '{branch}' is already in the stack"); + } + let parent_idx = self + .branches + .iter() + .position(|b| b.ref_name.as_ref() == parent) + .with_context(|| format!("Fork parent '{parent}' is not in the stack"))?; + self.branches.insert( + parent_idx, + WorkspaceStackBranch { + ref_name: branch, + archived: false, + parents: Some(vec![parent.to_owned()]), + }, + ); + self.validate_structure() + } + + /// Insert `branch` ON the existing edge from `child` down to `parent`: + /// `child` then rests on `branch`, which rests on `parent`. + pub fn insert_on_edge( + &mut self, + branch: gix::refs::FullName, + child: &gix::refs::FullNameRef, + parent: &gix::refs::FullNameRef, + ) -> anyhow::Result<()> { + use anyhow::{Context, bail}; + if self + .branches + .iter() + .any(|b| b.ref_name.as_ref() == branch.as_ref()) + { + bail!("Branch '{branch}' is already in the stack"); + } + let child_idx = self + .branches + .iter() + .position(|b| b.ref_name.as_ref() == child) + .with_context(|| format!("Edge child '{child}' is not in the stack"))?; + let parent_idx = self + .branches + .iter() + .position(|b| b.ref_name.as_ref() == parent) + .with_context(|| format!("Edge parent '{parent}' is not in the stack"))?; + let mut child_parents = self.effective_parents(child_idx); + let Some(pos) = child_parents.iter().position(|p| p.as_ref() == parent) else { + bail!("'{child}' does not rest on '{parent}' โ€” no such edge to insert on"); + }; + child_parents[pos] = branch.clone(); + self.branches[child_idx].parents = Some(child_parents); + self.branches.insert( + parent_idx, + WorkspaceStackBranch { + ref_name: branch, + archived: false, + parents: Some(vec![parent.to_owned()]), + }, + ); + self.validate_structure() + } + + /// Declare that `branch` ALSO rests on `extra_parent` โ€” a merge point. The extra + /// parent must be declared below `branch` (I4), and callers must ensure the branch + /// OWNS its merge commit (an empty branch cannot be a merge point โ€” I3, a + /// graph-level fact enforced by the operations layer). + pub fn add_merge_parent( + &mut self, + branch: &gix::refs::FullNameRef, + extra_parent: &gix::refs::FullNameRef, + ) -> anyhow::Result<()> { + use anyhow::{Context, bail}; + let idx = self + .branches + .iter() + .position(|b| b.ref_name.as_ref() == branch) + .with_context(|| format!("Branch '{branch}' is not in the stack"))?; + let mut parents = self.effective_parents(idx); + if parents.iter().any(|p| p.as_ref() == extra_parent) { + bail!("'{branch}' already rests on '{extra_parent}'"); + } + parents.push(extra_parent.to_owned()); + self.branches[idx].parents = Some(parents); + self.validate_structure() + } + /// The same as [`ref_name()`](Self::ref_name()), but returns an actual `Ref`. pub fn name(&self) -> Option<&gix::refs::FullNameRef> { self.ref_name().map(|rn| rn.as_ref()) diff --git a/crates/but-core/tests/core/ref_metadata.rs b/crates/but-core/tests/core/ref_metadata.rs index 149a8cec501..54e340c2fc1 100644 --- a/crates/but-core/tests/core/ref_metadata.rs +++ b/crates/but-core/tests/core/ref_metadata.rs @@ -1,7 +1,6 @@ mod workspace { - use bstr::ByteSlice; use but_core::ref_metadata::{ - ProjectedWorkspaceStack, StackId, + StackId, StackKind::{Applied, AppliedAndUnapplied}, Workspace, WorkspaceCommitRelation::{Merged, Outside}, @@ -344,6 +343,7 @@ Workspace { branches: vec![WorkspaceStackBranch { ref_name: outside_ref.to_owned(), archived: false, + parents: None, }], workspacecommit_relation: Outside, }, @@ -352,6 +352,7 @@ Workspace { branches: vec![WorkspaceStackBranch { ref_name: applied_ref.to_owned(), archived: false, + parents: None, }], workspacecommit_relation: Merged, }, @@ -374,368 +375,6 @@ Workspace { ); } - #[test] - fn reconcile_projected_stacks_starts_from_empty_metadata() -> anyhow::Result<()> { - let mut ws = Workspace::default(); - - ws.reconcile_projected_stacks( - [ - projected_stack(None, ["refs/heads/A"]), - projected_stack(None, ["refs/heads/C", "refs/heads/B"]), - ], - stack_id_from_name, - )?; - - snapbox::assert_data_eq!( - but_testsupport::sanitize_uuids_and_timestamps(format!("{ws:#?}")), - snapbox::str![[r#" -Workspace { - ref_info: RefInfo { created_at: None, updated_at: None }, - stacks: [ - WorkspaceStack { - id: 1, - branches: [ - WorkspaceStackBranch { - ref_name: "refs/heads/A", - archived: false, - }, - ], - workspacecommit_relation: Merged, - }, - WorkspaceStack { - id: 2, - branches: [ - WorkspaceStackBranch { - ref_name: "refs/heads/C", - archived: false, - }, - WorkspaceStackBranch { - ref_name: "refs/heads/B", - archived: false, - }, - ], - workspacecommit_relation: Merged, - }, - ], - target_ref: None, - target_commit_id: None, - push_remote: None, -} -"#]] - ); - Ok(()) - } - - #[test] - fn reconcile_projected_stacks_is_additive_and_preserves_existing_branch_metadata() - -> anyhow::Result<()> { - let archived_extra_ref = r("refs/heads/old-extra"); - let mut ws = workspace(vec![WorkspaceStack { - id: StackId::from_number_for_testing(1), - branches: vec![ - WorkspaceStackBranch { - ref_name: r("refs/heads/B").to_owned(), - archived: false, - }, - WorkspaceStackBranch { - ref_name: archived_extra_ref.to_owned(), - archived: true, - }, - ], - workspacecommit_relation: Outside, - }]); - - ws.reconcile_projected_stacks( - [projected_stack(None, ["refs/heads/C", "refs/heads/B"])], - stack_id_from_name, - )?; - - assert_eq!( - branch_names(&ws.stacks[0]), - ["refs/heads/C", "refs/heads/B", "refs/heads/old-extra"], - "projected branches are ordered first, while unused existing branches are retained" - ); - assert!( - ws.stacks[0].branches[2].archived, - "existing branch metadata is preserved" - ); - assert_eq!(ws.stacks[0].workspacecommit_relation, Merged); - snapbox::assert_data_eq!( - ws.to_debug(), - snapbox::str![[r#" -Workspace { - ref_info: RefInfo { created_at: None, updated_at: None }, - stacks: [ - WorkspaceStack { - id: 00000000-0000-0000-0000-000000000001, - branches: [ - WorkspaceStackBranch { - ref_name: "refs/heads/C", - archived: false, - }, - WorkspaceStackBranch { - ref_name: "refs/heads/B", - archived: false, - }, - WorkspaceStackBranch { - ref_name: "refs/heads/old-extra", - archived: true, - }, - ], - workspacecommit_relation: Merged, - }, - ], - target_ref: None, - target_commit_id: None, - push_remote: None, -} - -"#]] - ); - Ok(()) - } - - #[test] - fn reconcile_projected_stacks_rejects_duplicate_projected_names() { - let mut ws = Workspace::default(); - let err = ws - .reconcile_projected_stacks( - [ - projected_stack(None, ["refs/heads/A"]), - projected_stack(None, ["refs/heads/A"]), - ], - stack_id_from_name, - ) - .expect_err("duplicate projected names violate workspace metadata constraints"); - - assert_eq!( - err.to_string(), - "Cannot reconcile projected workspace: branch name 'refs/heads/A' occurs more than once" - ); - } - - #[test] - fn reconcile_projected_stacks_tolerates_duplicate_existing_metadata_names() -> anyhow::Result<()> - { - let matching_stack_id = StackId::from_number_for_testing(2); - let mut ws = workspace(vec![ - WorkspaceStack { - id: StackId::from_number_for_testing(1), - branches: vec![WorkspaceStackBranch { - ref_name: r("refs/heads/A").to_owned(), - archived: false, - }], - workspacecommit_relation: Merged, - }, - WorkspaceStack { - id: matching_stack_id, - branches: vec![WorkspaceStackBranch { - ref_name: r("refs/heads/A").to_owned(), - archived: true, - }], - workspacecommit_relation: Outside, - }, - ]); - - ws.reconcile_projected_stacks( - [projected_stack(Some(matching_stack_id), ["refs/heads/A"])], - stack_id_from_name, - )?; - // projected stacks prefer pulling from equally identified metadata stacks, so the Outside stacks turns Merged - snapbox::assert_data_eq!( - ws.to_debug(), - snapbox::str![[r#" -Workspace { - ref_info: RefInfo { created_at: None, updated_at: None }, - stacks: [ - WorkspaceStack { - id: 00000000-0000-0000-0000-000000000001, - branches: [ - WorkspaceStackBranch { - ref_name: "refs/heads/A", - archived: false, - }, - ], - workspacecommit_relation: Merged, - }, - WorkspaceStack { - id: 00000000-0000-0000-0000-000000000002, - branches: [ - WorkspaceStackBranch { - ref_name: "refs/heads/A", - archived: true, - }, - ], - workspacecommit_relation: Merged, - }, - ], - target_ref: None, - target_commit_id: None, - push_remote: None, -} - -"#]] - ); - - assert_eq!( - ws.stacks.len(), - 2, - "duplicate metadata hints in other stacks are tolerated" - ); - assert_eq!(branch_names(&ws.stacks[0]), ["refs/heads/A"]); - assert_eq!(branch_names(&ws.stacks[1]), ["refs/heads/A"]); - assert!( - ws.stacks[1].branches[0].archived, - "the preferred stack keeps its own branch metadata" - ); - assert_eq!(ws.stacks[1].workspacecommit_relation, Merged); - Ok(()) - } - - #[test] - fn reconcile_projected_stacks_moves_existing_branch_names_between_metadata_stacks() - -> anyhow::Result<()> { - let mut ws = workspace(vec![ - WorkspaceStack { - id: StackId::from_number_for_testing(1), - branches: vec![ - WorkspaceStackBranch { - ref_name: r("refs/heads/B").to_owned(), - archived: false, - }, - WorkspaceStackBranch { - ref_name: r("refs/heads/E").to_owned(), - archived: false, - }, - ], - workspacecommit_relation: Merged, - }, - WorkspaceStack { - id: StackId::from_number_for_testing(2), - branches: vec![ - WorkspaceStackBranch { - ref_name: r("refs/heads/C").to_owned(), - archived: false, - }, - WorkspaceStackBranch { - ref_name: r("refs/heads/D").to_owned(), - archived: true, - }, - ], - workspacecommit_relation: Outside, - }, - WorkspaceStack { - id: StackId::from_number_for_testing(3), - branches: vec![WorkspaceStackBranch { - ref_name: r("refs/heads/unrelated").to_owned(), - archived: false, - }], - workspacecommit_relation: Merged, - }, - ]); - - ws.reconcile_projected_stacks( - [projected_stack( - None, - ["refs/heads/D", "refs/heads/C", "refs/heads/B"], - )], - stack_id_from_name, - )?; - - // projected stack grouping moves existing branch metadata between stacks, while unrelated branch metadata remains - snapbox::assert_data_eq!( - ws.to_debug(), - snapbox::str![[r#" -Workspace { - ref_info: RefInfo { created_at: None, updated_at: None }, - stacks: [ - WorkspaceStack { - id: 00000000-0000-0000-0000-000000000001, - branches: [ - WorkspaceStackBranch { - ref_name: "refs/heads/E", - archived: false, - }, - ], - workspacecommit_relation: Merged, - }, - WorkspaceStack { - id: 00000000-0000-0000-0000-000000000002, - branches: [ - WorkspaceStackBranch { - ref_name: "refs/heads/D", - archived: true, - }, - WorkspaceStackBranch { - ref_name: "refs/heads/C", - archived: false, - }, - WorkspaceStackBranch { - ref_name: "refs/heads/B", - archived: false, - }, - ], - workspacecommit_relation: Merged, - }, - WorkspaceStack { - id: 00000000-0000-0000-0000-000000000003, - branches: [ - WorkspaceStackBranch { - ref_name: "refs/heads/unrelated", - archived: false, - }, - ], - workspacecommit_relation: Merged, - }, - ], - target_ref: None, - target_commit_id: None, - push_remote: None, -} - -"#]] - ); - Ok(()) - } - - #[test] - fn reconcile_projected_stacks_removes_stacks_emptied_by_moves() -> anyhow::Result<()> { - let mut ws = workspace(vec![ - WorkspaceStack { - id: StackId::from_number_for_testing(1), - branches: vec![WorkspaceStackBranch { - ref_name: r("refs/heads/B").to_owned(), - archived: false, - }], - workspacecommit_relation: Merged, - }, - WorkspaceStack { - id: StackId::from_number_for_testing(2), - branches: vec![WorkspaceStackBranch { - ref_name: r("refs/heads/C").to_owned(), - archived: false, - }], - workspacecommit_relation: Merged, - }, - ]); - - ws.reconcile_projected_stacks( - [projected_stack(None, ["refs/heads/C", "refs/heads/B"])], - stack_id_from_name, - )?; - - assert_eq!( - ws.stacks.len(), - 1, - "the stack that only contained moved branch B should be removed" - ); - assert_eq!( - branch_names(&ws.stacks[0]), - ["refs/heads/C", "refs/heads/B"] - ); - Ok(()) - } - fn workspace(stacks: Vec) -> Workspace { let mut ws = Workspace::default(); ws.stacks = stacks; @@ -748,19 +387,182 @@ Workspace { fn new_stack_id(_: &gix::refs::FullNameRef) -> StackId { StackId::generate() } - fn stack_id_from_name(name: &gix::refs::FullNameRef) -> StackId { - StackId::from_number_for_testing(name.shorten().chars().map(|c| c as u128).sum()) - } - fn projected_stack( - id: Option, - branches: [&str; N], - ) -> ProjectedWorkspaceStack { - ProjectedWorkspaceStack { - id, - branches: branches - .into_iter() - .map(|name| r(name).to_owned()) - .collect(), + + mod dag_declaration { + use super::{r, stack}; + use but_core::ref_metadata::WorkspaceCommitRelation::Merged; + + fn with_parents( + mut s: but_core::ref_metadata::WorkspaceStack, + assignments: &[(&str, &[&str])], + ) -> but_core::ref_metadata::WorkspaceStack { + for (name, parents) in assignments { + let idx = s + .branches + .iter() + .position(|b| b.ref_name.as_ref() == r(name)) + .expect("assigned branch exists"); + s.branches[idx].parents = Some(parents.iter().map(|p| r(p).to_owned()).collect()); + } + s + } + + #[test] + fn chains_always_pass_even_with_duplicates() { + let s = stack(1, Merged, ["refs/heads/A", "refs/heads/B", "refs/heads/A"]); + assert!(s.validate_structure().is_ok(), "legacy tolerance holds"); + assert_eq!(s.parent_edges(), [(0, 1), (1, 2)]); + } + + #[test] + fn diamond_is_valid_and_yields_its_edges() { + // tip M rests on both arms, which rest on base. + let s = with_parents( + stack( + 1, + Merged, + [ + "refs/heads/M", + "refs/heads/left", + "refs/heads/right", + "refs/heads/base", + ], + ), + &[ + ("refs/heads/M", &["refs/heads/left", "refs/heads/right"]), + ("refs/heads/left", &["refs/heads/base"]), + ("refs/heads/right", &["refs/heads/base"]), + ("refs/heads/base", &[]), + ], + ); + s.validate_structure().unwrap(); + assert_eq!(s.parent_edges(), [(0, 1), (0, 2), (1, 3), (2, 3)]); + } + + #[test] + fn structural_violations_are_hard_errors() { + let unknown = with_parents( + stack(1, Merged, ["refs/heads/A", "refs/heads/B"]), + &[("refs/heads/A", &["refs/heads/nope"])], + ); + assert!(unknown.validate_structure().is_err(), "unknown parent"); + + let upward = with_parents( + stack(1, Merged, ["refs/heads/A", "refs/heads/B"]), + &[("refs/heads/B", &["refs/heads/A"])], + ); + assert!(upward.validate_structure().is_err(), "parent above child"); + + let selfish = with_parents( + stack(1, Merged, ["refs/heads/A", "refs/heads/B"]), + &[("refs/heads/A", &["refs/heads/A"])], + ); + assert!(selfish.validate_structure().is_err(), "self parent"); + + let twice = with_parents( + stack(1, Merged, ["refs/heads/A", "refs/heads/B"]), + &[("refs/heads/A", &["refs/heads/B", "refs/heads/B"])], + ); + assert!(twice.validate_structure().is_err(), "duplicate parent"); + + let two_tips = with_parents( + stack( + 1, + Merged, + ["refs/heads/A", "refs/heads/B", "refs/heads/base"], + ), + &[ + ("refs/heads/A", &["refs/heads/base"]), + ("refs/heads/B", &["refs/heads/base"]), + ("refs/heads/base", &[]), + ], + ); + assert!(two_tips.validate_structure().is_err(), "two tips"); + + let dup_name = with_parents( + stack(1, Merged, ["refs/heads/A", "refs/heads/B", "refs/heads/A"]), + &[("refs/heads/A", &["refs/heads/B"])], + ); + assert!(dup_name.validate_structure().is_err(), "duplicate names"); + } + + #[test] + fn fork_insert_and_merge_parent_ops() { + let mut s = stack(1, Merged, ["refs/heads/top", "refs/heads/base"]); + s.add_fork(r("refs/heads/side").to_owned(), r("refs/heads/base")) + .unwrap(); + assert_eq!( + s.branches + .iter() + .map(|b| b.ref_name.to_string()) + .collect::>(), + ["refs/heads/top", "refs/heads/side", "refs/heads/base"], + ); + // top still (implicitly) rests on side?? NO: implied adjacency now points at + // side โ€” materialize the truth: the fork insertion may not corrupt top's + // parent, so validate catches nothing but the EDGE moved. Assert the edges. + assert_eq!(s.parent_edges(), [(0, 1), (1, 2)]); + + s.insert_on_edge( + r("refs/heads/mid").to_owned(), + r("refs/heads/side"), + r("refs/heads/base"), + ) + .unwrap(); + assert_eq!(s.parent_edges(), [(0, 1), (1, 2), (2, 3)]); + + s.add_merge_parent(r("refs/heads/top"), r("refs/heads/base")) + .unwrap(); + s.validate_structure().unwrap(); + assert!( + s.add_merge_parent(r("refs/heads/base"), r("refs/heads/top")) + .is_err(), + "merge parent above the branch is rejected" + ); + assert!( + s.insert_on_edge( + r("refs/heads/x").to_owned(), + r("refs/heads/top"), + r("refs/heads/mid"), + ) + .is_err(), + "no such edge" + ); + } + + #[test] + fn removal_splices_children_onto_the_removed_ones_parents() { + let mut ws = super::workspace(vec![with_parents( + stack( + 1, + Merged, + [ + "refs/heads/M", + "refs/heads/left", + "refs/heads/right", + "refs/heads/base", + ], + ), + &[ + ("refs/heads/M", &["refs/heads/left", "refs/heads/right"]), + ("refs/heads/left", &["refs/heads/base"]), + ("refs/heads/right", &["refs/heads/base"]), + ("refs/heads/base", &[]), + ], + )]); + assert!(ws.remove_segment(r("refs/heads/left"))); + let s = &ws.stacks[0]; + s.validate_structure().unwrap(); + assert_eq!( + s.branches[0].parents.as_ref().unwrap()[0].to_string(), + "refs/heads/base", + "M inherited left's parent in left's slot" + ); + assert_eq!( + s.parent_edges(), + [(0, 2), (0, 1), (1, 2)], + "base sits in left's old slot, then right, and right still rests on base" + ); } } fn stack( @@ -775,18 +577,12 @@ Workspace { .map(|name| WorkspaceStackBranch { ref_name: r(name).to_owned(), archived: false, + parents: None, }) .collect(), workspacecommit_relation, } } - fn branch_names(stack: &WorkspaceStack) -> Vec<&str> { - stack - .branches - .iter() - .map(|branch| branch.ref_name.as_bstr().to_str().expect("utf8")) - .collect() - } } mod project_meta { diff --git a/crates/but-debug/src/command/api.rs b/crates/but-debug/src/command/api.rs index 3517e9b0c58..897e9049c37 100644 --- a/crates/but-debug/src/command/api.rs +++ b/crates/but-debug/src/command/api.rs @@ -69,7 +69,8 @@ fn resolve_stack_id(ctx: &but_ctx::Context, stack: &str) -> Result { } let (_guard, _repo, workspace, _db) = ctx.workspace_and_db()?; - let mut matches = workspace.stacks.iter().filter_map(|workspace_stack| { + let display_stacks = workspace.display_stacks()?; + let mut matches = display_stacks.iter().filter_map(|workspace_stack| { stack_matches(workspace_stack, stack) .then_some(workspace_stack.id) .flatten() diff --git a/crates/but-debug/src/command/graph.rs b/crates/but-debug/src/command/graph.rs index f7256119950..745fbbd4136 100644 --- a/crates/but-debug/src/command/graph.rs +++ b/crates/but-debug/src/command/graph.rs @@ -112,14 +112,14 @@ fn emit_workspace( writeln!( err, "Workspace with {} stacks and {} segments across all stacks with {} commits total", - workspace.stacks.len(), + workspace.display_stacks()?.len(), workspace - .stacks + .display_stacks()? .iter() .map(|stack| stack.segments.len()) .sum::(), workspace - .stacks + .display_stacks()? .iter() .flat_map(|stack| stack.segments.iter().map(|segment| segment.commits.len())) .sum::(), diff --git a/crates/but-debug/src/command/workspace.rs b/crates/but-debug/src/command/workspace.rs index fd646d85bae..1f904c8bc5d 100644 --- a/crates/but-debug/src/command/workspace.rs +++ b/crates/but-debug/src/command/workspace.rs @@ -25,7 +25,7 @@ pub(crate) fn apply( let outcome = but_workspace::branch::apply( branch.as_ref(), - ws.clone(), + &ws, &repo, &mut meta, but_workspace::branch::apply::Options { @@ -66,7 +66,8 @@ pub(crate) fn unapply( )?; writeln!(out, "{outcome:#?}")?; - *ws = outcome.workspace.into_owned(); + // The display boundary: the context cache holds the pruned display workspace. + ws.adopt(outcome.workspace); emit_after(&ws, &mutation_args.debug, err) } @@ -83,8 +84,8 @@ fn emit_stack_summary(ws: &but_graph::Workspace, err: &mut dyn io::Write) -> Res writeln!( err, "workspace stacks after: {} ({})", - ws.stacks.len(), - ws.stacks + ws.display_stacks()?.len(), + ws.display_stacks()? .iter() .filter_map(|stack| stack.ref_name()) .map(|ref_name| ref_name.shorten().to_string()) diff --git a/crates/but-graph/src/build/layout.rs b/crates/but-graph/src/build/layout.rs index db6be28d881..4c55beca55c 100644 --- a/crates/but-graph/src/build/layout.rs +++ b/crates/but-graph/src/build/layout.rs @@ -60,14 +60,48 @@ pub(super) fn derive_ref_layout(data: &SegmentData, cg: &crate::CommitGraph) -> .entrypoint_sidx .context("BUG: must always set the entrypoint")?; let workspace_commit_id = data.entrypoint.filter(|&id| cg.is_managed_ws_commit(id)); - Ok(positions_from_segments( + let layout = positions_from_segments( cg, &data.segments, &data.extra_refs, &data.parent_ordered_targets(cg), entrypoint_sidx, workspace_commit_id, - )) + ); + // THE MERGE FORMULA: the stored workspace parents are the real parent array plus + // the chain structure's deliberately minted parents โ€” nothing else. Two properties, + // asserted: (1) the real parents keep their relative order (a no-op materialize + // must reproduce the merge, and operations must not reorder lanes), and (2) as a + // multiset the list is exactly real + minted (the wiring the list is derived FROM + // cannot grow or shrink the merge on its own). The weave position of a minted parent + // is shape-dependent and deliberately not pinned. + #[cfg(debug_assertions)] + if let Some(materialized) = &layout.materialized_ws_parents { + let real: Vec = cg.connected_parents(materialized.commit).collect(); + let mut expected: Vec = real + .iter() + .copied() + .chain(data.minted_ws_parents.iter().map(|&(_, anchor)| anchor)) + .collect(); + let mut stored = materialized.parents.clone(); + expected.sort_unstable(); + stored.sort_unstable(); + debug_assert_eq!( + stored, expected, + "the merge formula: stored ws parents = real parents + deliberate minted \ + (real: {real:?}, minted: {:?}, stored: {:?})", + data.minted_ws_parents, materialized.parents + ); + let mut remaining = materialized.parents.iter(); + debug_assert!( + real.iter() + .all(|parent| remaining.by_ref().any(|stored| stored == parent)), + "the merge formula: real parents keep their relative order \ + (real: {real:?}, stored: {:?})", + materialized.parents + ); + } + Ok(layout) } /// Compute the layout from the segments: per-segment entry rows (segment ref, then per commit its refs then the diff --git a/crates/but-graph/src/build/materialize.rs b/crates/but-graph/src/build/materialize.rs index 953e654c641..93e913405a5 100644 --- a/crates/but-graph/src/build/materialize.rs +++ b/crates/but-graph/src/build/materialize.rs @@ -285,12 +285,20 @@ pub(super) fn advanced_outside_decisions( if pinned_commits.contains(&tip) { continue; } - // The branch's outside commits, down to where it rejoins the workspace. + // The branch's outside commits, down to where it rejoins the workspace โ€” or an + // earlier decision's run: stack branches can sit on one another's spines + // (metadata lists them in any order), and each outside commit is materialized + // exactly once. let mut commits: Vec = Vec::new(); let mut cursor = Some(tip); let mut rejoin = None; while let Some(id) = cursor { - if in_set.contains(&id) { + if in_set.contains(&id) + || outside_owned.contains(&id) + // A pinned commit (a stored/extra target position) starts its own segment + // via the extra-target region โ€” the run stops above it. + || pinned_commits.contains(&id) + { rejoin = Some(id); break; } @@ -304,7 +312,14 @@ pub(super) fn advanced_outside_decisions( }; // Several stack branches can share the outside tip (e.g. an applied-branch preview where // `E` and `D` rest on the same not-yet-merged commit) โ€” the run is materialized ONCE. - if outside_owned.contains(&tip) || !owner_of.contains_key(&rejoin) { + // A rejoin INTO an earlier outside run or onto a pinned commit has its owner by + // construction (the pinned one built later, wired via pending edges); an in-set + // rejoin must have one. + if outside_owned.contains(&tip) + || !(owner_of.contains_key(&rejoin) + || outside_owned.contains(&rejoin) + || pinned_commits.contains(&rejoin)) + { continue; } // Named like any tip: ambiguous refs keep the segment anonymous (the walk's floating diff --git a/crates/but-graph/src/build/mod.rs b/crates/but-graph/src/build/mod.rs index cf84fc48509..2355d2ed7df 100644 --- a/crates/but-graph/src/build/mod.rs +++ b/crates/but-graph/src/build/mod.rs @@ -45,20 +45,32 @@ mod remotes; mod segment_data; /// Project a PROVIDED `cg` โ€” the write-through seam: an editor-mutated commit graph projects -/// like a fresh walk, with enrichment (refs, target, metadata) read from `repo`/`meta` amended -/// by `overlay` (default overlay = current state, a materialized rebase; a rebase's overlay -/// serves pending ref edits and entrypoint from memory, a preview). Dispatches like -/// `Graph::from_head`: managed when the workspace ref resolves to a -/// commit in `cg` (falling through to non-managed when the entrypoint lands outside the -/// workspace), non-managed otherwise. `None` when the entrypoint is unborn or unresolvable. +/// like a fresh walk, with enrichment read from `repo`/`meta` amended by `overlay`. `None` when +/// the entrypoint is unborn or unresolvable. pub(crate) fn workspace_from_commit_graph( + cg: CommitGraph, + repo: &gix::Repository, + meta: &T, + project_meta: but_core::ref_metadata::ProjectMeta, + options: crate::walk::Options, + overlay: crate::walk::Overlay, +) -> anyhow::Result> { + commit_graph_for_workspace(cg, repo, meta, project_meta, options, overlay)? + .map(|(cg, ctx)| crate::workspace::reconcile_workspace(cg, ctx)) + .transpose() +} + +/// The shared dispatch both twins project from: re-anchor `cg` in the on-disk world (targets, +/// remotes, HEAD), re-flag, and assemble managed or unmanaged โ€” yielding the assembled graph +/// and context, or `None` when there is nothing to project (unborn or out-of-graph entrypoint). +fn commit_graph_for_workspace( mut cg: CommitGraph, repo: &gix::Repository, meta: &T, project_meta: but_core::ref_metadata::ProjectMeta, options: crate::walk::Options, overlay: crate::walk::Overlay, -) -> anyhow::Result> { +) -> anyhow::Result> { let (overlay_repo, overlay_meta, overlay_entrypoint) = overlay.into_parts(repo, meta); let ws_ref: gix::refs::FullName = but_core::WORKSPACE_REF_NAME.try_into()?; let ws_tip_on_disk = resolve_ref_tip(&overlay_repo, ws_ref.as_ref())?; @@ -177,8 +189,10 @@ pub(crate) fn workspace_from_commit_graph( &overlay_repo, &overlay_meta, &ws_ref, - ws_commit, - entrypoint, + WorkspaceAnchors { + tip: ws_commit, + entrypoint, + }, entrypoint_ref, main_head_ref.as_ref(), project_meta.clone(), @@ -189,7 +203,7 @@ pub(crate) fn workspace_from_commit_graph( // the managed attempt touched (refs re-refreshed, layout re-authored), and the stray // managed-ws mark is only ever read for the entrypoint, provably a different commit here. if entrypoint_reached { - return crate::workspace::project_workspace(managed_cg, ctx).map(Some); + return Ok(Some((managed_cg, ctx))); } cg = managed_cg; } @@ -210,7 +224,7 @@ pub(crate) fn workspace_from_commit_graph( project_meta, options, )?; - crate::workspace::project_workspace(cg, ctx).map(Some) + Ok(Some((cg, ctx))) } /// Build the managed-workspace's ref layout and context onto its `CommitGraph`, deriving the @@ -253,8 +267,10 @@ pub fn graph_from_repository( &overlay_repo, &overlay_meta, &ws_ref, - ws_commit, - ep, + WorkspaceAnchors { + tip: ws_commit, + entrypoint: ep, + }, entrypoint_ref, walk_ref.as_ref(), project_meta, @@ -347,8 +363,10 @@ pub(crate) fn graph_from_repository_seeds( &overlay_repo, &overlay_meta, &ws_ref, - ws_commit, - entrypoint, + WorkspaceAnchors { + tip: ws_commit, + entrypoint, + }, ep_ref, entrypoint_ref.as_ref(), project_meta, @@ -400,8 +418,7 @@ fn assemble_managed( overlay_repo: &OverlayRepo<'_>, overlay_meta: &OverlayMetadata<'_, T>, ws_ref: &gix::refs::FullName, - ws_commit: gix::ObjectId, - entrypoint: gix::ObjectId, + anchors: WorkspaceAnchors, entrypoint_ref: Option, main_head_ref: Option<&gix::refs::FullName>, project_meta: but_core::ref_metadata::ProjectMeta, @@ -409,23 +426,30 @@ fn assemble_managed( ) -> anyhow::Result<(CommitGraph, GraphContext, bool)> { // Two candidates, usually the same commit: the facts' managed-merge check reads the // workspace tip, the ref layout's ws anchor reads the (checkout) entrypoint. - cg.mark_managed_ws_commit_by_message(repo, ws_commit); - cg.mark_managed_ws_commit_by_message(repo, entrypoint); + cg.mark_managed_ws_commit_by_message(repo, anchors.tip); + cg.mark_managed_ws_commit_by_message(repo, anchors.entrypoint); let ws_meta = overlay_meta.workspace(ws_ref.as_ref())?; let stack_branches = in_workspace_stack_branches(&ws_meta); + let stack_partition = in_workspace_stack_partition(&ws_meta)?; let inputs = enrichment_inputs(repo, overlay_repo, &project_meta, main_head_ref)?; - assemble( + let (mut cg, ctx, entrypoint_reached) = assemble( cg, overlay_repo, overlay_meta, - ws_commit, - entrypoint, + anchors, entrypoint_ref, Some(&stack_branches), inputs, project_meta, options, - ) + )?; + // The declared stack partition is pure metadata โ€” stamp it onto the stored layout after + // the graph-derived layout is built, giving the refโ†’stack partition one home the + // projection reads instead of re-consulting the workspace metadata. + if let Some(layout) = cg.layout.as_mut() { + layout.stacks = stack_partition; + } + Ok((cg, ctx, entrypoint_reached)) } /// Assemble the NON-managed graph from `cg`: no stack or workspace-ref passes, plus the @@ -447,8 +471,10 @@ fn assemble_unmanaged( cg, overlay_repo, overlay_meta, - head_tip, - head_tip, + WorkspaceAnchors { + tip: head_tip, + entrypoint: head_tip, + }, entrypoint_ref, None, inputs, @@ -458,6 +484,15 @@ fn assemble_unmanaged( Ok((cg, ctx)) } +/// The two workspace anchor commits, usually equal: the facts' managed-merge check reads +/// `tip`; the ref layout's workspace anchor reads the checkout `entrypoint`. Bundled so the +/// pair can't be transposed across the assembly helpers. +#[derive(Clone, Copy)] +struct WorkspaceAnchors { + tip: gix::ObjectId, + entrypoint: gix::ObjectId, +} + /// The build pipeline both entries share: plan, author the segments, apply persisted /// ad-hoc orders, derive and install the stored ref positions, stamp the REQUESTED /// entrypoint ref on the seed (it names the entry even where the walker nulled it for @@ -470,8 +505,7 @@ fn assemble( mut cg: CommitGraph, overlay_repo: &OverlayRepo<'_>, overlay_meta: &OverlayMetadata<'_, T>, - workspace_commit: gix::ObjectId, - entrypoint: gix::ObjectId, + anchors: WorkspaceAnchors, entrypoint_ref: Option, stack_branches: Option<&[Vec]>, inputs: EnrichmentInputs, @@ -491,8 +525,8 @@ fn assemble( )?; let b = BuildInputs { cg: &cg, - workspace_commit, - entrypoint, + workspace_commit: anchors.tip, + entrypoint: anchors.entrypoint, entrypoint_ref: entrypoint_ref.as_ref(), target: inputs.target, remote_tracking: &inputs.remote_tracking, @@ -688,6 +722,30 @@ fn in_workspace_stack_branches( .collect() } +/// The declared in-workspace stack partition: each in-workspace stack's id and its branch +/// names in declared (tipโ†’base) order, empty branches included. Mirrors the projection's +/// `in_ws_stacks` (`ws.stacks` filtered to in-workspace) with NO cross-stack dedup, so the +/// stored layout carries the exact partition the claiming needs โ€” unlike +/// [`in_workspace_stack_branches`], which dedups across stacks for the walk's segment authoring. +fn in_workspace_stack_partition( + ws: &but_core::ref_metadata::Workspace, +) -> anyhow::Result> { + ws.stacks + .iter() + .filter(|s| s.is_in_workspace()) + .map(|s| { + // A DECLARING stack must be well-formed to enter the layout at all; chains + // keep their legacy tolerance inside `validate_structure`. + s.validate_structure()?; + Ok(crate::ref_layout::StackPartition { + id: s.id, + branches: s.branches.iter().map(|b| b.ref_name.clone()).collect(), + parent_edges: s.parent_edges(), + }) + }) + .collect() +} + /// The local branch that names the segment at `c`, mirroring the walk's tiers: ABOVE the base /// the unique branch with GitButler METADATA wins (a stack branch beats the target's local ref, /// e.g. `A` over `main`); at/below the base (Integrated) the target's local position wins diff --git a/crates/but-graph/src/build/plan.rs b/crates/but-graph/src/build/plan.rs index ad2d2cb9177..08f8b277652 100644 --- a/crates/but-graph/src/build/plan.rs +++ b/crates/but-graph/src/build/plan.rs @@ -273,7 +273,6 @@ pub(super) fn chain_plan( let lists_per_commit = stack_lists_per_commit(cg, &combined); let at_or_below_bound: Option = ws_lower_bound.map(|lb| cg.ancestor_set(lb)); anonymize_shared_bases( - cg, facts, lists, &lists_per_commit, @@ -551,7 +550,6 @@ fn stack_lists_per_commit(cg: &CommitGraph, lists: &[Vec]) /// as its own empty chain. #[allow(clippy::too_many_arguments)] fn anonymize_shared_bases( - cg: &CommitGraph, facts: &Facts, lists: &[Vec], lists_per_commit: &IdMap, @@ -580,44 +578,15 @@ fn anonymize_shared_bases( } if let Some(lb) = ws_lower_bound && facts.boundaries.contains(&lb) - && name_of.get(&lb).is_some_and(|n| { - lists - .iter() - .any(|l| l.contains(n) && floats_at_lower_bound(cg, ws_lower_bound, l)) - }) + && name_of + .get(&lb) + .is_some_and(|n| lists.iter().any(|l| l.contains(n))) { name_of.remove(&lb); anonymous_bases.insert(lb); } } -/// Does this metadata stack list rest at the workspace lower bound โ€” some branch at the bound -/// itself and every other resolved branch integrated? -fn floats_at_lower_bound( - cg: &CommitGraph, - ws_lower_bound: Option, - list: &[gix::refs::FullName], -) -> bool { - let Some(lb) = ws_lower_bound else { - return false; - }; - let mut at_lb = false; - for b in list { - match cg.commit_by_ref(b.as_ref()) { - Some(c) if c == lb => at_lb = true, - Some(c) - if cg - .node(c) - .is_some_and(|n| !n.flags.contains(crate::CommitFlags::Integrated)) => - { - return false; - } - _ => {} - } - } - at_lb -} - /// The full set of names in use by `insert_empty_branches` time โ€” chain names plus everything /// the remote/target/tip/advanced passes will have created โ€” because the group naming's "does /// this ref already name a segment" ranges over every segment. @@ -753,8 +722,10 @@ fn thread_ref_groups( if !name_of.contains_key(&anchor) && (lists_per_commit.get(&commit).copied().unwrap_or(0) <= 1 || shared_commit_above_bound) - && !(Some(commit) == ws_lower_bound - && floats_at_lower_bound(cg, ws_lower_bound, &list)) + // A chain member never names the lower bound โ€” the bound's segment lies + // below the workspace, so a name spent there vanishes from the stacks. + // At-bound members float as empty segments instead. + && Some(commit) != ws_lower_bound && let Some(namer) = group.last() && !used.contains(namer) { diff --git a/crates/but-graph/src/build/remote_segments.rs b/crates/but-graph/src/build/remote_segments.rs index 0d134c15db1..71ccb75beed 100644 --- a/crates/but-graph/src/build/remote_segments.rs +++ b/crates/but-graph/src/build/remote_segments.rs @@ -110,15 +110,20 @@ pub(super) fn segment_ahead_region( let is_boundary = |c: gix::ObjectId| region.is_shape_boundary(cg, remote_tip, pinned_commits, c); // Merge-heavy regions make nearly every commit a tip, and a per-tip store scan is - // quadratic (20s on an 80k-commit repo) โ€” index first commits up front instead. - let mut first_commit_sidx: IdMap = IdMap::default(); + // quadratic (20s on an 80k-commit repo) โ€” index owned commits up front instead. + // EVERY owned commit counts, not just segment heads: a region boundary can land + // mid-run of an advanced-outside segment, and re-minting its tail would + // materialize those commits twice (one name, one reference). + let mut owned_commit_sidx: IdMap = IdMap::default(); let mut remote_first_commits: IdSet = IdSet::default(); for (sidx, seg) in store.segments.iter().enumerate() { - if let Some(&first) = seg.commits.first() { - first_commit_sidx.entry(cg.id_at(first)).or_insert(sidx); - if store.is_remote_segment(sidx) { - remote_first_commits.insert(cg.id_at(first)); - } + for &h in &seg.commits { + owned_commit_sidx.entry(cg.id_at(h)).or_insert(sidx); + } + if let Some(&first) = seg.commits.first() + && store.is_remote_segment(sidx) + { + remote_first_commits.insert(cg.id_at(first)); } } @@ -170,7 +175,7 @@ pub(super) fn segment_ahead_region( ahead_owner.insert(cg.id_at(c), tip); } let is_root = tip == remote_tip; - if !is_root && let Some(&existing) = first_commit_sidx.get(&tip) { + if !is_root && let Some(&existing) = owned_commit_sidx.get(&tip) { ahead_sidx.insert(tip, existing); reused.insert(tip); continue; @@ -206,8 +211,8 @@ pub(super) fn segment_ahead_region( let cut_ref = cut_ref.clone(); store.link_remote_to_local(sidx, &cut_ref, remote_tracking); } - if let Some(&first) = store.segments[sidx].commits.first() { - first_commit_sidx.entry(cg.id_at(first)).or_insert(sidx); + for &h in &store.segments[sidx].commits { + owned_commit_sidx.entry(cg.id_at(h)).or_insert(sidx); } ahead_sidx.insert(tip, sidx); } diff --git a/crates/but-graph/src/build/segment_data.rs b/crates/but-graph/src/build/segment_data.rs index cfff083d5f3..136b2d0b3ae 100644 --- a/crates/but-graph/src/build/segment_data.rs +++ b/crates/but-graph/src/build/segment_data.rs @@ -62,6 +62,12 @@ pub(super) struct SegmentData { /// Passive refs the BUILD adds onto a commit (by arena handle) beyond what the arena /// carries โ€” a float's displaced name. The position derivation merges them in. pub extra_refs: HashMap>, + /// Every DELIBERATE workspace parent the chain structure minted, `(insert position, + /// anchor commit)` in occurrence order โ€” with these, the stored materialized parents + /// are fully determined: the real parent array with these woven in. The layout + /// derivation asserts exactly that, so segment wiring cannot corrupt the merge + /// silently. + pub minted_ws_parents: Vec<(usize, gix::ObjectId)>, /// The entrypoint's segment โ€” the root of the layout's reach computation, and its name marks /// the HEAD ordinals. pub entrypoint_sidx: Option, @@ -244,13 +250,31 @@ impl SegmentData { /// Every segment's outgoing edges as target ordinals in FINAL parent order: real parents by /// their index in the source commit's parent array, commit-less edges after them in edge /// order, ordinals compacted by push order. + /// The first commit `sidx` leads to, resolving through its empty chain โ€” a fork or + /// dead end resolves to nothing. + fn first_commit_resolving_empties( + &self, + cg: &CommitGraph, + mut sidx: usize, + ) -> Option { + for _ in 0..self.segments.len() { + if let Some(&h) = self.segments[sidx].commits.first() { + return Some(cg.id_at(h)); + } + let &[only] = self.segments[sidx].connections.as_slice() else { + return None; + }; + sidx = only; + } + None + } + pub(super) fn parent_ordered_targets(&self, cg: &CommitGraph) -> OrderedTargets { let mut start = Vec::with_capacity(self.segments.len() + 1); let mut flat = Vec::new(); - let mut ordered: Vec<(usize, usize)> = Vec::new(); + let mut ordered: Vec<(i64, usize, usize)> = Vec::new(); start.push(0); for sidx in &self.segments { - let mut empty_branch_count = 0usize; ordered.clear(); // An edge's endpoints derive from the segments' commits: the source's LAST // commit connects to the target's FIRST (a stored edge record would only @@ -259,23 +283,53 @@ impl SegmentData { .commits .last() .map(|&h| cg.node_at(h).parent_ids.as_slice()); + let parent_number_of = |target: usize, taken: &[bool]| { + // A target routing a real parent keeps that parent's number โ€” resolved + // through its empty chain, since a splice re-routes the edge through + // empties without moving it (first splicer wins a contested number). + let dst = self.first_commit_resolving_empties(cg, target)?; + edge_parents + .and_then(|parents| parents.iter().position(|p| *p == dst)) + .filter(|&idx| !taken[idx]) + }; + let mut taken = vec![false; edge_parents.map_or(0, |p| p.len())]; + // Direct commit-bearing targets claim their parent numbers ahead of empty-chain routes. for &target in &sidx.connections { - let dst_id = self.segments[target].commits.first().map(|&h| cg.id_at(h)); - let real_parent_index = edge_parents - .zip(dst_id) - .and_then(|(parents, dst)| parents.iter().position(|p| *p == dst)); - let ordinal = match real_parent_index { - Some(idx) => idx, - None => { - let o = edge_parents.map_or(0, |p| p.len()) + empty_branch_count; - empty_branch_count += 1; - o - } + if let Some(idx) = self.segments[target] + .commits + .first() + .map(|&h| cg.id_at(h)) + .and_then(|dst| edge_parents.and_then(|p| p.iter().position(|x| *x == dst))) + { + taken[idx] = true; + } + } + // Targets with no real parent number (fresh minted parents, placed by insertion) + // stay WOVEN at their connection position: they sort right after the last + // numbered neighbor preceding them in connection order. + let mut prev_parent_number: i64 = -1; + let mut taken_by_route = vec![false; taken.len()]; + for (ci, &target) in sidx.connections.iter().enumerate() { + let direct = self.segments[target] + .commits + .first() + .map(|&h| cg.id_at(h)) + .and_then(|dst| edge_parents.and_then(|p| p.iter().position(|x| *x == dst))); + let parent_number = match direct { + Some(idx) => Some(idx), + None => parent_number_of(target, &taken) + .filter(|&idx| !std::mem::replace(&mut taken_by_route[idx], true)), }; - ordered.push((ordinal, target)); + match parent_number { + Some(idx) => { + prev_parent_number = idx as i64; + ordered.push((idx as i64, ci, target)); + } + None => ordered.push((prev_parent_number, ci, target)), + } } - ordered.sort_by_key(|(ordinal, _)| *ordinal); - flat.extend(ordered.iter().map(|&(_, t)| t)); + ordered.sort_by_key(|&(parent_number, ci, _)| (parent_number, ci)); + flat.extend(ordered.iter().map(|&(_, _, t)| t)); start.push(flat.len()); } OrderedTargets { start, flat } @@ -337,23 +391,25 @@ pub(super) fn build(inputs: GraphInputs<'_>) -> SegmentData { inputs.remote_tracking, ); let before_chains = store.segments.len(); + let mut pending_edges: Vec<(usize, gix::ObjectId)> = Vec::new(); if inputs.stack_branches.is_some() || !inputs.layout.chains.is_empty() { build_chain_structure( inputs.cg, &mut store, &sidx_of_tip, + inputs.stack_branches, inputs.workspace_commit, inputs.ws_empty_ref, inputs.advanced_outside, inputs.layout, inputs.ad_hoc_chain_start, inputs.remote_tracking, + &mut pending_edges, ); } // The coverage gates evaluate the PRE-CHAIN view: segments minted by the chain-structure // pass don't count as coverage. let chain_created: HashSet = (before_chains..store.segments.len()).collect(); - let mut pending_edges: Vec<(usize, gix::ObjectId)> = Vec::new(); add_remote_segments( inputs.cg, &mut store, @@ -462,6 +518,7 @@ pub(super) fn build(inputs: GraphInputs<'_>) -> SegmentData { inputs.cg, &mut store, None, + None, &inputs.layout.chains[inputs.ad_hoc_chain_start..], inputs.layout, inputs.remote_tracking, @@ -585,12 +642,14 @@ fn build_chain_structure( cg: &CommitGraph, store: &mut SegmentData, sidx_of_tip: &IdMap, + stack_branches: Option<&[Vec]>, workspace_commit: gix::ObjectId, ws_empty_ref: Option<&gix::refs::FullName>, advanced_outside: &[AdvancedOutside], layout: &LayoutPlan, ad_hoc_chain_start: usize, remote_tracking: &HashMap, + pending_edges: &mut Vec<(usize, gix::ObjectId)>, ) { let mut ws_empty_sidx = None; if let Some(ws_ref) = ws_empty_ref @@ -602,28 +661,33 @@ fn build_chain_structure( ws_empty_sidx = Some(sidx); } for decision in advanced_outside { - let Some(owner) = store.sidx_by_commit(cg, decision.rejoin) else { - continue; - }; + // A rejoin onto a PINNED commit has no owner yet โ€” the extra-target region builds + // it later โ€” so that edge waits in `pending_edges` like any late wiring. + let owner = store.sidx_by_commit(cg, decision.rejoin); let sidx = store.add_segment(decision.name.clone(), decision.commits.clone()); if let Some(name) = decision.name.as_ref() { store.set_tip(sidx, decision.tip); store.segments[sidx].remote_tracking_ref_name = remote_tracking.get(name).cloned(); // Only a NAMED advanced branch is the in-workspace segment's sibling; the workspace // position itself never links to outside content. - if decision.rejoin != workspace_commit + if let Some(owner) = owner + && decision.rejoin != workspace_commit && store.segments[owner].sibling_segment_id.is_none() { store.segments[owner].sibling_segment_id = Some(sidx); } } - store.connect(sidx, owner); + match owner { + Some(owner) => store.connect(sidx, owner), + None => pending_edges.push((sidx, decision.rejoin)), + } } let ws_sidx = ws_empty_sidx.or_else(|| sidx_of_tip.get(&workspace_commit).copied()); insert_empty_branches( cg, store, ws_sidx, + stack_branches, &layout.chains[..ad_hoc_chain_start.min(layout.chains.len())], layout, remote_tracking, @@ -637,6 +701,7 @@ fn insert_empty_branches( cg: &CommitGraph, store: &mut SegmentData, ws_sidx: Option, + stack_branches: Option<&[Vec]>, chains: &[RefChain], layout: &LayoutPlan, remote_tracking: &HashMap, @@ -650,6 +715,18 @@ fn insert_empty_branches( store.segments[anchor].remote_tracking_branch_segment_id = None; } for (li, chain) in chains.iter().enumerate() { + // A chain with CONTENT anywhere (a member naming a commit-bearing segment, e.g. + // an advanced-outside run) lives with that content: its empties ride existing + // structure. A PURE-EMPTY chain is its own lane and needs a workspace parent. + let chain_has_content = stack_branches + .and_then(|branches| branches.get(li)) + .is_some_and(|branches| { + branches.iter().any(|b| { + store + .sidx_by_ref(b) + .is_some_and(|sidx| !store.segments[sidx].commits.is_empty()) + }) + }); // Without a workspace anchor (the late ad-hoc pass), the chain hangs from its // own first anchor's segment โ€” the pure ad-hoc shape. let mut from_sidx = ws_sidx.or_else(|| { @@ -680,6 +757,30 @@ fn insert_empty_branches( } = &group.placement && !members.is_empty() { + // The chain's own commit-bearing segments, as they stand NOW โ€” a + // member named at mint or by an earlier anchor is a route the + // splice may interpose on. + let member_segments: HashSet = stack_branches + .and_then(|branches| branches.get(li)) + .map(|branches| { + branches + .iter() + .filter_map(|b| store.sidx_by_ref(b)) + .collect() + }) + .unwrap_or_default(); + let hang = if from_sidx == ws_sidx { + SpliceHang::Workspace { + position: li, + anchor_commit: commit, + anchor_in_ws: cg + .node(commit) + .is_some_and(|n| n.flags.contains(crate::CommitFlags::InWorkspace)), + members: chain_has_content.then_some(&member_segments), + } + } else { + SpliceHang::Thread + }; insert_empty_chain_above( store, from_sidx, @@ -687,7 +788,7 @@ fn insert_empty_branches( members, remote_tracking, *into_owning_chain, - (from_sidx == ws_sidx).then_some(li), + hang, ); } from_sidx = Some(anchor); @@ -695,9 +796,53 @@ fn insert_empty_branches( } } -/// Splice a run of empty named segments in above `anchor`: incoming edges redirect to the top -/// empty (or a fresh edge from `from_sidx` joins it), and the bottom empty connects down. -#[allow(clippy::too_many_arguments)] +/// How a splice reaches its anchor when the chain's thread has no edge there yet. +enum SpliceHang<'a> { + /// The chain hangs from the WORKSPACE โ€” this is its first placed anchor. + Workspace { + /// The chain's metadata index: where a fresh workspace parent inserts. + position: usize, + /// The anchor commit โ€” recorded when a fresh parent is minted. + anchor_commit: gix::ObjectId, + /// Whether the anchor commit is workspace territory. A fresh parent is never + /// minted beyond it โ€” an empty resting on an unpulled target tip must not + /// drag that territory into the materialized merge. + anchor_in_ws: bool, + /// The chain's member segments when the chain has CONTENT anywhere โ€” routes + /// its empties ride. `None` for a pure-empty chain, which never rides: + /// its fresh workspace parent IS what makes an empty stack its own lane (and + /// the stored surplus parents drive its projection claim). + members: Option<&'a HashSet>, + }, + /// The chain's thread continues from its previous anchor's segment. + Thread, +} + +/// A segment (other than the thread itself) carrying an edge into `anchor` that the +/// splice may re-route, per `eligible`. +fn route_into_anchor( + store: &SegmentData, + thread: usize, + anchor: usize, + eligible: impl Fn(usize, &Segment) -> bool, +) -> Option { + (0..store.segments.len()).find(|&sidx| { + sidx != thread + && !store.is_remote_segment(sidx) + && eligible(sidx, &store.segments[sidx]) + && store.segments[sidx].connections.contains(&anchor) + }) +} + +/// Splice a run of empty named segments in above `anchor`, per THE PLACEMENT RULE: +/// a splice rides the first existing route into its anchor owned by its chain โ€” the +/// thread from the chain's previous anchor (or the workspace itself, redirected in +/// place), a member's own segment, or an unowned anonymous lane (the run the +/// projection claims for the chain whose branch rests on its parent). Only a chain +/// owning no route creates an edge: a fresh workspace parent onto a workspace-interior +/// anchor when hanging from the workspace (the empty-stack lane), or a plain +/// connection when continuing its thread. `into_owning_chain` is the plan's stronger +/// grant โ€” the splice owns the anchor outright, so ANY edge into it re-routes. fn insert_empty_chain_above( store: &mut SegmentData, from_sidx: Option, @@ -705,7 +850,7 @@ fn insert_empty_chain_above( empties: &[gix::refs::FullName], remote_tracking: &HashMap, into_owning_chain: bool, - fresh_connection_position: Option, + hang: SpliceHang<'_>, ) { let ids: Vec = empties .iter() @@ -719,7 +864,8 @@ fn insert_empty_chain_above( return; }; if let Some(from_sidx) = from_sidx { - let mut redirected = false; + // The thread's own edges into the anchor re-route in place (the owning-chain + // grant widens this to every edge). let redirect_sources: Vec = if into_owning_chain { (0..store.segments.len()) .filter(|&sidx| !ids.contains(&sidx) && !store.is_remote_segment(sidx)) @@ -727,29 +873,46 @@ fn insert_empty_chain_above( } else { vec![from_sidx] }; + let mut redirected = false; for source in redirect_sources { redirected |= store.retarget_edges(source, anchor, top) > 0; } if !redirected { - let find_parent = |require_commits: bool| { - (0..store.segments.len()).find(|&sidx| { - sidx != from_sidx - && !store.is_remote_segment(sidx) - && (!require_commits || !store.segments[sidx].commits.is_empty()) - && store.segments[sidx].connections.contains(&anchor) + let route = if into_owning_chain { + // Plan-granted ownership: adopt any parent, commit-bearing first. + route_into_anchor(store, from_sidx, anchor, |_, seg| !seg.commits.is_empty()) + .or_else(|| route_into_anchor(store, from_sidx, anchor, |_, _| true)) + } else if let SpliceHang::Workspace { + anchor_in_ws: true, + members: Some(members), + .. + } = hang + { + // A content chain rides its own member's segment or an unowned lane. + route_into_anchor(store, from_sidx, anchor, |sidx, seg| { + (seg.name.is_none() || members.contains(&sidx)) && !seg.commits.is_empty() }) + } else { + None }; - let chain_parent = into_owning_chain - .then(|| find_parent(true).or_else(|| find_parent(false))) - .flatten(); - match chain_parent { - Some(parent) => { - store.retarget_edges(parent, anchor, top); + match (route, hang) { + (Some(route), _) => { + store.retarget_edges(route, anchor, top); } - None => match fresh_connection_position { - Some(parent_number) => store.insert_connect_at(from_sidx, parent_number, top), - None => store.connect(from_sidx, top), - }, + ( + None, + SpliceHang::Workspace { + position, + anchor_commit, + anchor_in_ws: true, + .. + }, + ) => { + store.minted_ws_parents.push((position, anchor_commit)); + store.insert_connect_at(from_sidx, position, top) + } + (None, SpliceHang::Workspace { .. }) => {} + (None, SpliceHang::Thread) => store.connect(from_sidx, top), } } } @@ -779,6 +942,15 @@ fn cover_explicit_seeds( if cg.node(t.id).is_none() { continue; } + if std::env::var("DBG_COVER").is_ok() { + eprintln!( + "DBG cover: seed {:?}@{} covered={:?} excluding={:?}", + t.ref_name.as_ref().map(|n| n.shorten().to_string()), + t.id, + store.sidx_by_commit(cg, t.id), + store.sidx_by_commit_excluding(cg, t.id, chain_created), + ); + } match store.sidx_by_commit_excluding(cg, t.id, chain_created) { None => segment_ahead_region( cg, diff --git a/crates/but-graph/src/commit_graph.rs b/crates/but-graph/src/commit_graph.rs index 750e7367352..1918980e416 100644 --- a/crates/but-graph/src/commit_graph.rs +++ b/crates/but-graph/src/commit_graph.rs @@ -61,6 +61,9 @@ pub struct CommitGraph { /// [`CommitFlags`](crate::CommitFlags) so it neither perturbs the walk's goal bits nor the /// segment fingerprint; used to tell a real managed merge from a ws ref advanced past it. managed_ws_commits: HashSet, + /// Per managed workspace commit: the parentโ†”stack binding its header recorded, if + /// any โ€” each slot the bound stack's TIP branch name, `None` = declared anonymous. + ws_parent_bindings: HashMap>>, /// When built [from the walk](Self::from_walk): whether the traversal stopped queueing after /// hitting the hard limit. Derived graphs must carry it forward. pub(crate) hard_limit_hit: bool, @@ -232,13 +235,29 @@ impl CommitGraph { id: gix::ObjectId, ) { if let Ok(commit) = repo.find_commit(id) - && let Ok(message) = commit.message_raw() - && crate::workspace::commit::is_managed_workspace_by_message(message) + && let Ok(decoded) = commit.decode() + && crate::workspace::commit::is_managed_workspace_by_message(decoded.message) { self.managed_ws_commits.insert(id); + if let Some(binding) = decoded + .extra_headers() + .find(but_core::commit::HEADERS_WORKSPACE_PARENTS_FIELD) + .and_then(but_core::commit::decode_workspace_parents) + { + self.ws_parent_bindings.insert(id, binding); + } } } + /// The recorded parentโ†”stack binding of managed workspace commit `id`, if its + /// header carried one โ€” writer intent, for the claiming to read instead of infer. + pub(crate) fn ws_parent_binding( + &self, + id: gix::ObjectId, + ) -> Option<&[Option]> { + self.ws_parent_bindings.get(&id).map(|v| v.as_slice()) + } + /// Where traversal/HEAD started (a checkout inside a stack), if any. The projection forces a /// segment boundary here โ€” there is always a segment starting at the entrypoint. pub fn entrypoint(&self) -> Option { @@ -807,6 +826,9 @@ impl CommitGraph { if self.managed_ws_commits.remove(&old) { self.managed_ws_commits.insert(id); } + // The rewritten commit on disk was stripped of any recorded binding (picks do + // that), so the parsed copy must not outlive it. + self.ws_parent_bindings.remove(&old); } /// Overwrite `idx`'s whole parent array โ€” the editor's ordered-parent number write. Every new parent number diff --git a/crates/but-graph/src/debug.rs b/crates/but-graph/src/debug.rs index 0cc76e6106f..f35f82bc74d 100644 --- a/crates/but-graph/src/debug.rs +++ b/crates/but-graph/src/debug.rs @@ -18,29 +18,10 @@ impl crate::Workspace { /// short name. pub fn anonymized(mut self, remotes: &gix::remote::Names) -> anyhow::Result { let mut anon = RefAnonymizer::new(remotes); - for stack in &mut self.stacks { - for segment in &mut stack.segments { - if let Some(ri) = segment.ref_info.as_mut() { - anon.apply(&mut ri.ref_name)?; - } - if let Some(rn) = segment.remote_tracking_ref_name.as_mut() { - anon.apply(rn)?; - } - for ri in segment - .commits - .iter_mut() - .flat_map(|c| c.refs.iter_mut()) - .chain( - segment - .commits_on_remote - .iter_mut() - .flat_map(|c| c.refs.iter_mut()), - ) - { - anon.apply(&mut ri.ref_name)?; - } - } - } + // Rewrite the SOURCES โ€” public fact fields, context, frame, and the graph itself. + // Every derivation (the segment graph below, the display per call) re-derives from + // them, so anonymized names flow to renderers and the falsified originals + // cannot linger anywhere. match &mut self.kind { crate::workspace::WorkspaceKind::Managed { ref_info } | crate::workspace::WorkspaceKind::ManagedMissingWorkspaceCommit { ref_info } => { @@ -71,7 +52,13 @@ impl crate::Workspace { anon.apply(rn)?; } } - self.ctx.remote_tracking = std::mem::take(&mut self.ctx.remote_tracking) + // Alias in sorted-key order: hash-map iteration would hand out aliases in a + // different order every process. + let mut remote_tracking: Vec<_> = std::mem::take(&mut self.ctx.remote_tracking) + .into_iter() + .collect(); + remote_tracking.sort(); + self.ctx.remote_tracking = remote_tracking .into_iter() .map(|(mut local, mut remote)| { anon.apply(&mut local)?; @@ -79,7 +66,64 @@ impl crate::Workspace { Ok((local, remote)) }) .collect::>()?; + let mut branch_details: Vec<_> = std::mem::take(&mut self.ctx.branch_details) + .into_iter() + .collect(); + branch_details.sort_by(|(a, _), (b, _)| a.cmp(b)); + self.ctx.branch_details = branch_details + .into_iter() + .map(|(mut name, details)| { + anon.apply(&mut name)?; + Ok((name, details)) + }) + .collect::>()?; + for rn in self.ctx.ad_hoc_branch_stack_orders.iter_mut().flatten() { + anon.apply(rn)?; + } + if let Some(wm) = self.ctx.workspace_meta.as_mut() { + anon.apply(&mut wm.ref_name)?; + for rn in wm + .metadata + .stacks + .iter_mut() + .flat_map(|s| s.branches.iter_mut().map(|b| &mut b.ref_name)) + { + anon.apply(rn)?; + } + } + // The FRAME mirrors the public fields and feeds the derivations verbatim. + match &mut self.frame.kind { + crate::workspace::WorkspaceKind::Managed { ref_info } + | crate::workspace::WorkspaceKind::ManagedMissingWorkspaceCommit { ref_info } => { + anon.apply(&mut ref_info.ref_name)?; + } + crate::workspace::WorkspaceKind::AdHoc => {} + } + if let Some(md) = self.frame.metadata.as_mut() { + for rn in md + .stacks + .iter_mut() + .flat_map(|s| s.branches.iter_mut().map(|b| &mut b.ref_name)) + { + anon.apply(rn)?; + } + } + if let Some(ri) = self.frame.tip_ref_info.as_mut() { + anon.apply(&mut ri.ref_name)?; + } + if let Some(rn) = self.frame.entry_ref.as_mut() { + anon.apply(rn)?; + } + if let Some(rn) = self.frame.lower_bound_ref_name.as_mut() { + anon.apply(rn)?; + } + if let Some(t) = self.frame.target_ref.as_mut() { + anon.apply(&mut t.name)?; + } self.commit_graph.anonymize_refs(&mut anon)?; + // Re-derive the segment graph from the anonymized sources โ€” coherent by construction + // with what the display will re-derive. + self.segment_graph = crate::workspace::SegmentGraph::from_stacks(&self.derive_reconciled()); Ok(self) } } @@ -208,6 +252,41 @@ impl crate::CommitGraph { if let Some(rn) = self.entrypoint_ref_mut() { anon.apply(rn)?; } + for seed in &mut self.seeds { + if let Some(rn) = seed.ref_name.as_mut() { + anon.apply(rn)?; + } + if let Some(crate::SegmentMetadata::Workspace(ws)) = seed.metadata.as_mut() { + for rn in ws + .stacks + .iter_mut() + .flat_map(|s| s.branches.iter_mut().map(|b| &mut b.ref_name)) + { + anon.apply(rn)?; + } + } + } + // The stored layout speaks names throughout โ€” groups, facts, head refs, and the + // declared stack partition all feed re-derivations and must alias coherently. + if let Some(layout) = self.layout.as_mut() { + for group in layout.groups.iter_mut().flat_map(|(_, groups)| groups) { + for rn in &mut group.members { + anon.apply(rn)?; + } + if let Some(rn) = group.attach.as_mut() { + anon.apply(rn)?; + } + } + for (rn, _) in &mut layout.facts { + anon.apply(rn)?; + } + for rn in &mut layout.head_refs { + anon.apply(rn)?; + } + for rn in layout.stacks.iter_mut().flat_map(|s| &mut s.branches) { + anon.apply(rn)?; + } + } self.rebuild_by_ref(); Ok(()) } diff --git a/crates/but-graph/src/projection/api/legacy.rs b/crates/but-graph/src/projection/api/legacy.rs index 863893a4501..6716ef4033e 100644 --- a/crates/but-graph/src/projection/api/legacy.rs +++ b/crates/but-graph/src/projection/api/legacy.rs @@ -65,7 +65,7 @@ impl Workspace { first_parent: FirstParent, ) -> Result> { let mut heads = self - .stacks + .display_stacks()? .iter() .filter_map(|stack| stack.tip_skip_empty()) .collect::>(); diff --git a/crates/but-graph/src/projection/api/mod.rs b/crates/but-graph/src/projection/api/mod.rs index ebd74dfb70c..6da117d2013 100644 --- a/crates/but-graph/src/projection/api/mod.rs +++ b/crates/but-graph/src/projection/api/mod.rs @@ -2,20 +2,14 @@ use std::borrow::Cow; use anyhow::Context; use bstr::BStr; -use but_core::{RefMetadata, extract_remote_name_and_short_name, ref_metadata::StackId}; +use but_core::{RefMetadata, extract_remote_name_and_short_name}; use tracing::instrument; use crate::{ Workspace, - workspace::{ - Stack, StackCommit, StackSegment, WorkspaceKind, find_segment_owner_indexes_by_refname, - }, + workspace::{Segment, SegmentStack, WorkspaceKind}, }; -/// A utility type to represent `(stack_idx, segment_idx, commit_idx)`, indexing one commit -/// as `ws.stacks[stack_idx].segments[segment_idx].commits[commit_idx]`. -pub type CommitOwnerIndexes = (usize, usize, usize); - mod queries; pub use queries::StackTip; #[cfg(feature = "legacy")] @@ -81,36 +75,11 @@ impl Workspace { Ok(()) } - /// Preview `commit_graph` โ€” typically a not-yet-materialized rebase's mutated arena, which - /// already IS the next workspace state โ€” with the rebase's pending ref edits and entrypoint - /// served from `overlay`; no repository rewalk. The materialized twin is - /// [`Self::refresh_from_commit_graph`]. - /// - /// Falls back to an overlay rewalk ([`Self::redo`]) when the commit graph has nothing to - /// project: the entrypoint is unborn or points outside the graph. - #[instrument( - name = "Workspace::preview_from_commit_graph", - level = "debug", - skip_all, - err(Debug) - )] - pub fn preview_from_commit_graph( - &self, - commit_graph: crate::CommitGraph, - repo: &gix::Repository, - meta: &impl RefMetadata, - overlay: crate::walk::Overlay, - ) -> anyhow::Result { - match crate::workspace_from_commit_graph( - commit_graph, - repo, - meta, - self.ctx.project_meta.clone(), - self.ctx.options.clone(), - overlay.clone(), - )? { - Some(preview) => Ok(preview), - None => self.redo(repo, meta, overlay), + /// Adopt the workspace a mutating operation returned. `None` โ€” the operation changed + /// nothing โ€” leaves this workspace untouched, which is exactly right: it is still current. + pub fn adopt(&mut self, updated: Option) { + if let Some(updated) = updated { + *self = updated; } } } @@ -129,6 +98,11 @@ impl Workspace { self.tip_ref_info.as_ref().map(|ri| ri.ref_name.as_ref()) } + /// Like [`Self::ref_name()`], but owned โ€” for callers that store or move the name. + pub fn ref_name_owned(&self) -> Option { + self.ref_name().map(ToOwned::to_owned) + } + /// Like [`Self::ref_name()`], but return a generic `` name for unnamed workspaces. pub fn ref_name_display(&self) -> &BStr { self.ref_name() @@ -196,96 +170,69 @@ impl Workspace { /// Return `true` if the workspace itself is where `HEAD` is pointing to. /// If `false`, one of the stack-segments is checked out instead. + /// + /// Resolved against the segment graph, not the pruned display: entrypoint-ness is a + /// structural fact, and pruning can drop the very segment carrying the entrypoint mark. pub fn is_entrypoint(&self) -> bool { - self.stacks - .iter() - .all(|s| s.segments.iter().all(|s| !s.is_entrypoint)) - } - - /// Return `true` if the branch with `name` is the workspace target or the targets local tracking branch. - pub fn is_target_or_its_local_tracking(&self, name: &gix::refs::FullNameRef) -> bool { - let Some(t) = self.target_ref.as_ref() else { - return false; - }; - - t.ref_name.as_ref() == name - || self - .local_tracking_branch(t.ref_name.as_ref()) - .is_some_and(|local_tracking_ref| local_tracking_ref.as_ref() == name) - } - - /// Lookup a triple obtained by [`Self::find_owner_indexes_by_commit_id()`] or panic. - pub fn lookup_commit(&self, (stack_idx, seg_idx, cidx): CommitOwnerIndexes) -> &StackCommit { - &self.stacks[stack_idx].segments[seg_idx].commits[cidx] - } - - /// Find a stack with the given `id` or error. - pub fn try_find_stack_by_id(&self, id: impl Into>) -> anyhow::Result<&Stack> { - let id = id.into(); - self.find_stack_by_id(id) - .with_context(|| format!("Couldn't find stack with id {id:?} in workspace")) - } - - /// Find a stack with the given `id`. - pub fn find_stack_by_id(&self, id: impl Into>) -> Option<&Stack> { - let id = id.into(); - self.stacks.iter().find(|s| s.id == id) + // A frame FACT, not a derivation: the entrypoint marks are gated on exactly this + // condition (`mark_entrypoint_segments`), verified equivalent across the suites + // (249 probes, both branches). The tripwire below keeps debug builds honest. + let fact = !(self.frame.entry_inside && self.frame.kind.has_managed_ref()); + fact } - /// Try to find the `(stack_idx, segment_idx, commit_idx)` to be able to access the commit with `oid` in this workspace - /// as `ws.stacks[stack_idx].segments[segment_idx].commits[commit_idx]`. - pub fn find_owner_indexes_by_commit_id( - &self, - oid: impl Into, - ) -> Option { - let oid = oid.into(); - self.stacks + /// The `(stack_idx, segment_idx)` of the entry segment in the SEGMENT GRAPH, located from the + /// FRAME facts โ€” its named segment first, else the segment holding the entry's resolved + /// commit โ€” the same order [`mark_entrypoint_segments`](super::init) writes the display + /// marks in. + pub(crate) fn entry_location(&self) -> Option<(usize, usize)> { + let (ep_name, ep_commit) = self.frame.entry_facts(&self.ctx); + self.segment_graph + .stacks .iter() .enumerate() .find_map(|(stack_idx, stack)| { stack .segments .iter() - .enumerate() - .find_map(|(seg_idx, seg)| { - seg.commits.iter().enumerate().find_map(|(cidx, c)| { - (c.id == oid).then_some((stack_idx, seg_idx, cidx)) - }) - }) + .position(|seg| ep_name.is_some() && seg.ref_name() == ep_name) + .map(|idx| (stack_idx, idx)) }) + .or_else(|| ep_commit.and_then(|id| self.segment_containing(id))) } - /// Like [`Self::find_owner_indexes_by_commit_id()`], but returns an error if the commit can't be found. - pub fn try_find_owner_indexes_by_commit_id( - &self, - oid: impl Into, - ) -> anyhow::Result { - let oid = oid.into(); - self.find_owner_indexes_by_commit_id(oid) - .with_context(|| format!("Commit {oid} isn't part of the workspace")) + /// Whether the entry "marks" the segment-graph stack at `stack_idx`, replicating the display + /// marker's exact semantics ([`mark_entrypoint_segments`](super::init)): nothing marks + /// unless the mark gate holds (entry inside a managed workspace); a name match anywhere + /// restricts marking to name matches; otherwise the entry's resolved commit marks every + /// stack containing it. + pub fn entry_marks_stack(&self, stack_idx: usize) -> bool { + use super::frame::EntryMark; + let Some(stack) = self.segment_graph.stacks.get(stack_idx) else { + return false; + }; + match self.frame.entry_mark(&self.ctx, |name| { + self.segment_graph.segment_location_by_name(name).is_some() + }) { + EntryMark::None => false, + EntryMark::ByName(name) => stack.segments.iter().any(|s| s.ref_name() == Some(name)), + EntryMark::ByCommit(id) => stack + .segments + .iter() + .any(|segment| segment.commits.contains(&id)), + } } - /// Try to find the `(stack_idx, segment_idx)` to be able to access the named segment going by `name`. - /// Access the segment as `ws.stacks[stack_idx].segments[segment_idx]` - pub fn find_segment_owner_indexes_by_refname( - &self, - ref_name: &gix::refs::FullNameRef, - ) -> Option<(usize, usize)> { - find_segment_owner_indexes_by_refname(&self.stacks, ref_name) - } + /// Return `true` if the branch with `name` is the workspace target or the targets local tracking branch. + pub fn is_target_or_its_local_tracking(&self, name: &gix::refs::FullNameRef) -> bool { + let Some(t) = self.target_ref.as_ref() else { + return false; + }; - /// Like [`Self::find_segment_owner_indexes_by_refname`], but fails with an error. - pub fn try_find_segment_owner_indexes_by_refname( - &self, - name: &gix::refs::FullNameRef, - ) -> anyhow::Result<(usize, usize)> { - self.find_segment_owner_indexes_by_refname(name) - .with_context(|| { - format!( - "Couldn't find any stack that contained the branch named '{}'", - name.shorten() - ) - }) + t.ref_name.as_ref() == name + || self + .local_tracking_branch(t.ref_name.as_ref()) + .is_some_and(|local_tracking_ref| local_tracking_ref.as_ref() == name) } /// Return `true` if `name` is contained in the workspace as segment. @@ -301,55 +248,64 @@ impl Workspace { if self.is_entrypoint() { self.refname_is_segment(name) } else { - let Some((entrypoint_stack, entrypoint_segment_idx)) = - self.stacks.iter().find_map(|stack| { - stack - .segments - .iter() - .enumerate() - .find_map(|(idx, segment)| segment.is_entrypoint.then_some((stack, idx))) - }) - else { - return false; - }; - entrypoint_stack - .segments - .get(entrypoint_segment_idx..) - .into_iter() - .any(|segments| { - segments - .iter() - .any(|s| s.ref_name().is_some_and(|rn| rn == name)) - }) + // The entry segment is located from the FRAME facts over the SEGMENT GRAPH; the + // display-flavored entrypoint marks are not consulted (the tripwire keeps + // debug builds honest). + let reachable = self.entry_location().is_some_and(|(stack_idx, idx)| { + self.segment_graph + .stacks + .get(stack_idx) + .is_some_and(|stack| { + stack + .segments + .get(idx..) + .into_iter() + .any(|segments| segments.iter().any(|s| s.ref_name() == Some(name))) + }) + }); + reachable } } - /// Try to find `name` in any named [`StackSegment`] and return it along with the stack containing it. + /// Try to find `name` in any named [`Segment`] and return it along with the + /// [`SegmentStack`] containing it. Resolved against the reconciled partition (total, + /// pre-prune), so it answers for natural stacks and totality-kept branches โ€” the + /// operation-facing lookup. pub fn find_segment_and_stack_by_refname( &self, name: &gix::refs::FullNameRef, - ) -> Option<(&Stack, &StackSegment)> { - let (stack_idx, seg_idx) = find_segment_owner_indexes_by_refname(&self.stacks, name)?; - let stack = &self.stacks[stack_idx]; + ) -> Option<(&SegmentStack, &Segment)> { + let (stack_idx, seg_idx) = self.segment_graph.segment_location_by_name(name)?; + let stack = &self.segment_graph.stacks[stack_idx]; Some((stack, &stack.segments[seg_idx])) } - /// Try to find a commit in the workspace and return it along with the segment and stack containing it. + /// The [`Segment`] whose first-parent extent holds `commit_id`, with its + /// [`SegmentStack`] โ€” resolved against the reconciled partition, the operation-facing + /// structure, not the pruned display. pub fn find_commit_and_containers( &self, commit_id: gix::ObjectId, - ) -> Option<(&Stack, &StackSegment, &StackCommit)> { - let (stack_idx, seg_idx, commit_idx) = self.find_owner_indexes_by_commit_id(commit_id)?; - let stack = &self.stacks[stack_idx]; - let seg = &stack.segments[seg_idx]; - Some((stack, seg, &seg.commits[commit_idx])) + ) -> Option<(&SegmentStack, &Segment)> { + let (stack_idx, seg_idx) = self.segment_containing(commit_id)?; + let stack = &self.segment_graph.stacks[stack_idx]; + Some((stack, &stack.segments[seg_idx])) + } + + /// Like [`Self::find_commit_and_containers()`], but errors if `commit_id` isn't in the workspace. + pub fn try_find_commit_and_containers( + &self, + commit_id: gix::ObjectId, + ) -> anyhow::Result<(&SegmentStack, &Segment)> { + self.find_commit_and_containers(commit_id) + .with_context(|| format!("Commit {commit_id} isn't part of the workspace")) } /// Like [`Self::find_segment_and_stack_by_refname`], but fails with an error. pub fn try_find_segment_and_stack_by_refname( &self, name: &gix::refs::FullNameRef, - ) -> anyhow::Result<(&Stack, &StackSegment)> { + ) -> anyhow::Result<(&SegmentStack, &Segment)> { self.find_segment_and_stack_by_refname(name) .with_context(|| { format!( @@ -413,7 +369,7 @@ impl std::fmt::Debug for Workspace { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct(&format!("Workspace({})", self.debug_string())) .field("kind", &self.kind) - .field("stacks", &self.stacks) + .field("stacks", &self.display_stacks().unwrap_or_default()) .field("metadata", &self.metadata) .field("target_ref", &self.target_ref) .field("target_commit", &self.target_commit) diff --git a/crates/but-graph/src/projection/api/queries.rs b/crates/but-graph/src/projection/api/queries.rs index 785654083ae..b3a0f4ab80f 100644 --- a/crates/but-graph/src/projection/api/queries.rs +++ b/crates/but-graph/src/projection/api/queries.rs @@ -5,7 +5,7 @@ use anyhow::Context; -use crate::{Workspace, workspace::StackSegment}; +use crate::{Workspace, workspace::find_segment_owner_indexes_by_refname}; /// Legacy query helpers kept for callers that still depend on compatibility /// semantics. @@ -36,33 +36,81 @@ impl Workspace { self.tip_commit_id } - /// Where the branch named `name` rests: its own tip commit, or โ€” when it is empty โ€” - /// the first commit of a segment below it in the same stack, or the stack's base. - /// `None` if `name` isn't a workspace stack branch. - pub fn branch_resting_commit_id(&self, name: &gix::refs::FullNameRef) -> Option { - let (stack_idx, seg_idx) = self.find_segment_owner_indexes_by_refname(name)?; - let stack = &self.stacks[stack_idx]; - stack.segments[seg_idx..] - .iter() - .find_map(|segment| segment.commits.first().map(|commit| commit.id)) - .or_else(|| stack.base()) - } - - /// Like [`Self::branch_resting_commit_id()`], but with distinct errors for an unknown - /// branch and for the impossible no-base case. + /// The commit the branch `name` RESTS on in the segment graph โ€” its own tip, seen + /// through empty segments to the first tip at-or-below, else the stack base โ€” with + /// distinct errors for an unknown branch and the impossible no-base case. The + /// operation-facing answer; [`Workspace::branch_resting_commit_id_in_display()`] + /// answers over the pruned display instead. pub fn try_branch_resting_commit_id( &self, name: &gix::refs::FullNameRef, ) -> anyhow::Result { - let (stack_idx, seg_idx) = self.try_find_segment_owner_indexes_by_refname(name)?; - let stack = &self.stacks[stack_idx]; - stack.segments[seg_idx..] + let (stack_idx, seg_idx) = self + .segment_graph + .segment_location_by_name(name) + .with_context(|| { + format!( + "Couldn't find any stack that contained the branch named '{}'", + name.shorten() + ) + })?; + let stack = &self.segment_graph.stacks[stack_idx]; + stack + .segments_at_or_below(seg_idx) .iter() - .find_map(|segment| segment.commits.first().map(|commit| commit.id)) - .or_else(|| stack.base()) + .find_map(|segment| segment.tip()) + .or(stack.base) .context("BUG: we should always see through to the base or eligible commits") } + /// The nearest branch naming a metadata-carrying segment relative to `commit_id`'s + /// segment in the segment graph: searching DOWN toward the base from that segment + /// first, then UP toward the tip. Returns the branch and whether it sits at-or-below + /// the commit's segment (`true`) or above it (`false`) โ€” the anchor a new dependent + /// reference orders against. `None` if `commit_id` isn't in the segment graph, or + /// its stack has no such named segment. + pub fn nearest_named_metadata_segment( + &self, + commit_id: gix::ObjectId, + ) -> Option<(&gix::refs::FullNameRef, bool)> { + let answer = { + let (stack_idx, seg_idx) = self.segment_containing(commit_id)?; + let stack = &self.segment_graph.stacks[stack_idx]; + let named_metadata = |i: usize| { + let s = stack.segments.get(i)?; + s.ref_name + .as_ref() + .map(|n| n.as_ref()) + .filter(|_| s.has_metadata) + }; + (0..=seg_idx) + .rev() + .find_map(|i| named_metadata(i).map(|rn| (rn, true))) + .or_else(|| { + (seg_idx + 1..stack.segments.len()) + .find_map(|i| named_metadata(i).map(|rn| (rn, false))) + }) + }; + answer + } + + /// The `(stack_idx, segment_idx)` of the segment-graph segment owning `commit_id`: a + /// first-parent walk from each segment's tip down to (exclusive) its boundary โ€” the + /// segment graph's commit-containment recipe. + pub(crate) fn segment_containing(&self, commit_id: gix::ObjectId) -> Option<(usize, usize)> { + self.segment_graph + .stacks + .iter() + .enumerate() + .find_map(|(stack_idx, stack)| { + stack.segments.iter().enumerate().find_map(|(seg_idx, s)| { + s.commits + .contains(&commit_id) + .then_some((stack_idx, seg_idx)) + }) + }) + } + /// The commit that `name` points to, directly or indirectly: the segment named `name` โ€” /// or holding a commit that carries `name` in its refs โ€” resolves it, seeing through /// empty segments. Tags may or may not be in the graph, depending on how it was built. @@ -75,6 +123,22 @@ impl Workspace { }) } + /// Whether `name` is its stack's TIP branch โ€” the topmost named segment of the + /// stack containing it in the segment graph. The question operations ask + /// as "does removing this branch drop the whole stack's workspace parent, or is it + /// a mid-stack de-listing?". + /// + /// Answered from the reconciled VIEW, not a raw graph walk: tip-ness is a RECONCILED + /// property. Two probed counter-examples pin why โ€” two stacks sharing one commit + /// are separated only by metadata (a graph walk sees one group), and an at-bound + /// stack exists only through the reconciliation's surfacing passes. + pub fn is_stack_tip(&self, name: &gix::refs::FullNameRef) -> bool { + let answer = self.segment_graph.stacks.iter().any(|stack| { + stack.segments.iter().find_map(|segment| segment.ref_name()) == Some(name) + }); + answer + } + /// Return the full [`RefInfo`](crate::RefInfo) of the workspace tip segment, if the tip /// is named. Unlike [`Self::ref_name()`](Self::ref_name) this includes worktree and /// pointed-to-commit information. @@ -152,6 +216,25 @@ impl Workspace { } } +/// Display-only points of interest โ€” resolved against the pruned display projection. +impl Workspace { + /// Where the branch named `name` rests: its own tip commit, or โ€” when it is empty โ€” + /// the first commit of a segment below it in the same stack, or the stack's base. + /// `None` if `name` isn't a workspace stack branch. + pub fn branch_resting_commit_id_in_display( + &self, + name: &gix::refs::FullNameRef, + ) -> Option { + let stacks = self.display_stacks().ok()?; + let (stack_idx, seg_idx) = find_segment_owner_indexes_by_refname(&stacks, name)?; + let stack = stacks.get(stack_idx)?; + stack.segments[seg_idx..] + .iter() + .find_map(|segment| segment.commits.first().map(|commit| commit.id)) + .or_else(|| stack.base()) + } +} + /// # Carried Data /// /// Data assembled during graph construction and carried into the projection. @@ -429,12 +512,15 @@ impl Workspace { return None; } let on = layout.positioned_on(name)?; - let stack_tips: Vec<(gix::ObjectId, &StackSegment)> = self + // The SEGMENT GRAPH's tip set, not the pruned display: a superseding tip pruned from the + // display still supersedes. (Tripwire below keeps it honest against the reconciliation.) + let stack_tips: Vec<(gix::ObjectId, &crate::workspace::Segment)> = self + .segment_graph .stacks .iter() .filter_map(|stack| { - let tip = stack.segments.first()?; - tip.commits.first().map(|c| (c.id, tip)) + let top = stack.top()?; + top.tip().map(|id| (id, top)) }) .collect(); @@ -458,8 +544,8 @@ impl Workspace { if let Some((_, segment)) = stack_tips.iter().find(|(cid, _)| *cid == id) { // Prune below a match, like the segment traversal did. matches.push(StackTip { - ref_name: segment.ref_name().map(|rn| rn.to_owned()), - commit_id: segment.commits.first().map(|c| c.id), + ref_name: segment.ref_name.clone(), + commit_id: segment.tip(), }); continue; } diff --git a/crates/but-graph/src/projection/collect.rs b/crates/but-graph/src/projection/collect.rs index 93ef8659c66..3dc6f960d1c 100644 --- a/crates/but-graph/src/projection/collect.rs +++ b/crates/but-graph/src/projection/collect.rs @@ -3,6 +3,7 @@ use std::collections::{HashMap, HashSet}; +use crate::ref_layout::StackPartition; use crate::workspace::{GraphContext, Stack, StackCommit, StackSegment}; use crate::{CommitGraph, RefInfo}; use but_core::ref_metadata; @@ -10,7 +11,7 @@ use but_core::ref_metadata; /// How the view anchors; only these three states are legal (a managed COMMIT implies a /// managed ref). #[derive(Clone, Copy, PartialEq, Eq)] -enum ViewAnchor { +pub(super) enum ViewAnchor { /// The anchor is a managed workspace merge commit (with metadata). ManagedCommit, /// A managed workspace REF exists, but its commit is missing or unmanaged. @@ -20,7 +21,7 @@ enum ViewAnchor { } impl ViewAnchor { - fn new(managed_commit: bool, has_managed_ref: bool) -> Self { + pub(super) fn new(managed_commit: bool, has_managed_ref: bool) -> Self { if managed_commit { debug_assert!(has_managed_ref, "a managed commit implies a managed ref"); Self::ManagedCommit @@ -44,16 +45,18 @@ impl ViewAnchor { } } -/// Derive the workspace's stacks from the layout the builder stored on `cg` and the -/// workspace metadata's stack lists. +/// Derive the workspace's stacks from the layout the builder stored on `cg`: its ref +/// groups AND its declared stack partition ([`StackPartition`]). /// /// The parent array drives: one stack per materialized parent, in parent order โ€” /// metadata-less parents included. A parent claiming a ref chain (matched by the -/// chain's top position) gets its seed-born empty branches from the metadata list, -/// which is what the stored layout cannot carry. Positioned naming refs carve the -/// commit runs; another chain's naming ref ends a stack's territory, while chainless -/// naming refs (a reachable target local, say) become segments within the reaching -/// stack. `lower_bound` is the workspace frame's bound. +/// chain's top position) gets its seed-born empty branches from that stored partition โ€” +/// the declared, ordered branch list the ref groups alone cannot reconstruct. Positioned +/// naming refs carve the commit runs; another chain's naming ref ends a stack's territory, +/// while chainless naming refs (a reachable target local, say) become segments within the +/// reaching stack. `lower_bound` is the workspace frame's bound. `ws_meta` no longer feeds +/// the partition โ€” only the frame decisions the layout cannot carry (whether a workspace is +/// anchored at all, and inactive-stack identity in `bind_stack_identity`). /// /// Returns `None` when the graph has no layout to read (unborn refs) โ€” the caller /// falls back to a single named empty stack. @@ -68,14 +71,18 @@ pub(crate) fn stacks_from_arena( ) -> Option> { let layout = cg.layout()?; let (ws_commit, materialized_ws_parents) = resolve_view_anchor(cg, layout, ws_meta, entry_ref)?; - let in_ws_stacks: Vec<_> = ws_meta - .map(|meta| { - meta.stacks - .iter() - .filter(|stack| stack.is_in_workspace()) - .collect() - }) - .unwrap_or_default(); + // The declared in-workspace stack partition now lives on the layout โ€” an exact mirror of + // ws_meta's in-workspace stacks โ€” so the claiming reads its CONTENT from there rather than + // re-consulting the metadata. Whether the partition APPLIES stays a frame decision: a + // frame anchored OUTSIDE the managed workspace (a plain `main` checkout) carries no + // workspace metadata and projects no stacks, so the layout's build-time partition โ€” which + // outlives the anchor โ€” must not leak in. `ws_meta.is_some()` is that frame signal (a + // presence check, not a re-read of the partition's contents). + let in_ws_stacks: Vec<&StackPartition> = if ws_meta.is_some() { + layout.stacks.iter().collect() + } else { + Vec::new() + }; if entry_is_unapplied_branch( cg, @@ -103,7 +110,7 @@ pub(crate) fn stacks_from_arena( let mut chain_of_name = HashMap::<&gix::refs::FullNameRef, usize>::new(); for (li, meta_stack) in in_ws_stacks.iter().enumerate() { for branch in &meta_stack.branches { - chain_of_name.insert(branch.ref_name.as_ref(), li); + chain_of_name.insert(branch.as_ref(), li); } } let entry_ref = entry_ref.or_else(|| cg.entrypoint_ref()); @@ -168,6 +175,7 @@ pub(crate) fn stacks_from_arena( )); } split_at_bound_empties_from_content_stacks(&mut stacks, &walk, lower_bound, anchor); + surface_unprojected_chains(&mut stacks, &walk, anchor); prune_all_integrated_anonymous(&mut stacks); // A TRUE ad-hoc view always has its one stack, if only an anonymous shell โ€” // a detached checkout of fully integrated territory still IS a checkout. @@ -181,6 +189,69 @@ pub(crate) fn stacks_from_arena( Some(stacks) } +/// THE reconciliation pass โ€” the single owner of "applied means projected", decided +/// only after every run is collected: an applied chain's branches that appear NOWHERE +/// in the projection surface as empty traces at their rest, homed into the chain's +/// stack. No claim pass can decide this (whether another stack's walk absorbed a +/// branch is only known once collection is done), and a branch that never surfaces +/// would be silently unapplied by the next metadata write. +fn surface_unprojected_chains(stacks: &mut Vec, walk: &RunWalk<'_>, anchor: ViewAnchor) { + if !anchor.has_managed_ref() { + return; + } + let mut projected: HashSet = stacks + .iter() + .flat_map(|stack| stack.segments.iter()) + .filter_map(|segment| segment.ref_name().map(ToOwned::to_owned)) + .collect(); + let displayed_commits: HashSet = stacks + .iter() + .flat_map(|stack| stack.segments.iter()) + .flat_map(|segment| &segment.commits) + .map(|commit| commit.id) + .collect(); + for meta_stack in &walk.in_ws_stacks { + // Metadata written mid-operation can list a branch twice; the first occurrence + // shapes the trace, matching `claim_chain`. + let mut names: Vec<&gix::refs::FullName> = Vec::new(); + for branch in &meta_stack.branches { + if !names.iter().any(|n| **n == *branch) { + names.push(branch); + } + } + // A branch another stack's walk absorbed is represented there; only the + // remainder needs a home. (A fully absorbed chain surfaces nothing.) + names.retain(|name| !projected.contains(*name)); + if names.is_empty() { + continue; + } + let mut sip = SegmentsInProgress::new(); + walk.emit_leftover_traces(&names, &mut sip, &displayed_commits); + let segments = sip.segments; + if segments.is_empty() { + continue; + } + projected.extend( + segments + .iter() + .filter_map(|segment| segment.ref_name().map(ToOwned::to_owned)), + ); + // The chain may already have a projected stack (part walked, part traced) โ€” + // the remainder settles there rather than minting a second stack with its id. + if let Some(stack) = stacks + .iter_mut() + .find(|stack| stack.id == Some(meta_stack.id)) + { + stack.segments.extend(segments); + } else { + stacks.push(Stack::from_base_and_segments_raw( + segments, + Some(meta_stack.id), + )); + } + } +} + /// One run of the workspace: the metadata chain claiming it (if any), the commit /// the run starts on, and whether this stack owns and walks the run โ€” a chain /// claiming a REAL parent edge (or the entry commit) owns its run, even a chain of @@ -204,12 +275,12 @@ type PosByName<'a> = /// dependent empty resting on another stack's commit steal that stack's run. fn chain_claims( pos_by_name: &PosByName<'_>, - meta_stack: &but_core::ref_metadata::WorkspaceStack, + meta_stack: &StackPartition, key: gix::ObjectId, ) -> bool { let mut positions = meta_stack.branches.iter().filter_map(|b| { pos_by_name - .get(b.ref_name.as_ref()) + .get(b.as_ref()) .filter(|(facts, _)| facts.names_segment) .map(|&(facts, on)| (facts.names_empty_segment, on)) }); @@ -244,7 +315,7 @@ fn chain_claims( #[expect(clippy::too_many_arguments)] fn build_run_order( cg: &CommitGraph, - in_ws_stacks: &[&ref_metadata::WorkspaceStack], + in_ws_stacks: &[&StackPartition], pos_by_name: &PosByName<'_>, ws_commit: gix::ObjectId, real_ws_parents: &[gix::ObjectId], @@ -264,7 +335,16 @@ fn build_run_order( if anchor.managed_commit() { claims.claim_chains_topping_the_entry(ws_commit); } - claims.claim_real_parents(real_ws_parents); + // A recorded parentโ†”stack binding โ€” the merge writer's intent, stamped into the + // workspace commit's headers โ€” replaces the position inference for the real parents; + // a binding that fails validation is stale and inference takes over wholesale. + let bound = anchor + .managed_commit() + .then(|| cg.ws_parent_binding(ws_commit)) + .flatten(); + if !bound.is_some_and(|bindings| claims.claim_bound_parents(real_ws_parents, bindings)) { + claims.claim_real_parents(real_ws_parents); + } claims.claim_surplus_materialized_parents(materialized_ws_parents, real_ws_parents); // Unclaimed chains resting on the bound still surface as stacks at their // metadata index, with or without stored chain parents. A workspace REF makes this @@ -275,6 +355,7 @@ fn build_run_order( claims.claim_duplicate_runs(); } claims.claim_at_bound_empties(); + claims.claim_legs_of_anonymous_runs(); } (claims.order, claims.claimed) } @@ -290,7 +371,7 @@ fn unclaimed_empties_in_table_order<'a>( cg: &CommitGraph, layout: &'a crate::ref_layout::RefLayout, idx: &LayoutIndexes<'_>, - in_ws_stacks: &[&ref_metadata::WorkspaceStack], + in_ws_stacks: &[&StackPartition], claimed: &[bool], ) -> HashMap> { let mut unclaimed_empties_at = HashMap::>::new(); @@ -300,12 +381,11 @@ fn unclaimed_empties_in_table_order<'a>( && name.category() == Some(gix::reference::Category::LocalBranch) && !is_implementation_ref(cg, name.as_ref()) }) { - if in_ws_stacks.iter().enumerate().any(|(li, m)| { - claimed[li] - && m.branches - .iter() - .any(|b| b.ref_name.as_ref() == name.as_ref()) - }) { + if in_ws_stacks + .iter() + .enumerate() + .any(|(li, m)| claimed[li] && m.branches.iter().any(|b| b.as_ref() == name.as_ref())) + { continue; } let Some(&(_, Some(on))) = idx.pos_by_name.get(name.as_ref()) else { @@ -320,7 +400,7 @@ fn unclaimed_empties_in_table_order<'a>( /// each pass matches unclaimed metadata chains to run-start commits its own way. struct RunClaims<'a> { cg: &'a CommitGraph, - in_ws_stacks: &'a [&'a ref_metadata::WorkspaceStack], + in_ws_stacks: &'a [&'a StackPartition], pos_by_name: &'a PosByName<'a>, lower_bound: Option, // (chain, run start, owns_run): a chain claiming a REAL parent edge (or the entry @@ -351,6 +431,69 @@ impl<'a> RunClaims<'a> { /// Every real parent edge is a run in parent order, claimed by the chain whose /// positioned branch sits on it (anonymous when none does). + /// Claim every real parent from the RECORDED binding โ€” writer intent instead of + /// position inference. All-or-nothing: any slot failing validation (a tip name + /// that resolves to no or several declared chains, a doubly-bound chain, or a + /// chain whose real branch rests on a DIFFERENT parent) rejects the whole binding + /// as stale, and the caller falls back to [`Self::claim_real_parents`]. A `None` + /// slot is a DECLARED anonymous parent. + fn claim_bound_parents( + &mut self, + real_ws_parents: &[gix::ObjectId], + bindings: &[Option], + ) -> bool { + if bindings.len() != real_ws_parents.len() { + return false; + } + let parent_set: HashSet = real_ws_parents.iter().copied().collect(); + let mut per_slot = Vec::with_capacity(bindings.len()); + let mut used = HashSet::new(); + for (parent, binding) in real_ws_parents.iter().zip(bindings) { + let Some(name) = binding else { + per_slot.push(None); + continue; + }; + let mut candidates = self + .in_ws_stacks + .iter() + .enumerate() + .filter(|(_, c)| c.branches.first().map(|b| b.as_ref()) == Some(name.as_ref())); + let Some((li, _)) = candidates.next() else { + return false; + }; + if candidates.next().is_some() { + return false; + } + if self.claimed[li] || !used.insert(li) { + return false; + } + // A chain with a real branch positioned ON some ws parent must be bound to + // exactly that slot โ€” catches swapped stale bindings. Positions outside the + // parent set (advanced-outside shapes) trust the recorded intent. + let real_on_parent = self.in_ws_stacks[li].branches.iter().find_map(|b| { + let &(facts, on) = self.pos_by_name.get(b.as_ref())?; + (facts.names_segment && !facts.names_empty_segment) + .then_some(on?) + .filter(|pos| parent_set.contains(pos)) + }); + if real_on_parent.is_some_and(|pos| pos != *parent) { + return false; + } + per_slot.push(Some(li)); + } + for (parent, slot) in real_ws_parents.iter().zip(per_slot) { + if let Some(li) = slot { + self.claimed[li] = true; + } + self.order.push(Run { + chain: slot, + start: *parent, + owns_run: true, + }); + } + true + } + fn claim_real_parents(&mut self, real_ws_parents: &[gix::ObjectId]) { for parent in real_ws_parents { let chain = self.in_ws_stacks.iter().enumerate().find_map(|(li, meta)| { @@ -367,6 +510,44 @@ impl<'a> RunClaims<'a> { } } + /// Last-chance pass: a run still anonymous after every direct claim may top a + /// chain's leg whose tip commits lost their refs (a deleted branch leaves anonymous + /// commits above the chain's remaining branches). Walk each such run's first-parent + /// leg and claim the first still-unclaimed chain whose positioned branch sits on it, + /// so the chain's same-commit empties keep their home. Runs late so the direct + /// passes keep first pick of chains. + fn claim_legs_of_anonymous_runs(&mut self) { + for i in 0..self.order.len() { + if self.order[i].chain.is_some() || !self.order[i].owns_run { + continue; + } + let Some(li) = self.chain_on_leg_below(self.order[i].start) else { + continue; + }; + self.claimed[li] = true; + self.order[i].chain = Some(li); + } + } + + /// The first unclaimed chain whose positioned branch sits on a commit of the + /// first-parent leg strictly below `start`, stopping at (and never testing) the + /// lower bound โ€” bound content belongs to the at-bound machinery. + fn chain_on_leg_below(&self, start: gix::ObjectId) -> Option { + let mut cursor = start; + for _ in 0..10_000 { + cursor = self.cg.all_parent_ids(cursor).first().copied()?; + if Some(cursor) == self.lower_bound { + return None; + } + if let Some(li) = self.in_ws_stacks.iter().enumerate().find_map(|(li, meta)| { + (!self.claimed[li] && chain_claims(self.pos_by_name, meta, cursor)).then_some(li) + }) { + return Some(li); + } + } + None + } + /// The chain parents ARE the resolved set of runs: every real parent plus a fresh /// connection per chain the builder wired in besides them (duplicates meaning two /// chains share one run). Each surplus entry claims its chain and inserts at the @@ -405,7 +586,7 @@ impl<'a> RunClaims<'a> { for (li, meta) in self.in_ws_stacks.iter().enumerate() { if self.claimed[li] { self.claimed_names - .extend(meta.branches.iter().map(|b| b.ref_name.as_ref())); + .extend(meta.branches.iter().map(|b| b.as_ref())); } } } @@ -421,7 +602,7 @@ impl<'a> RunClaims<'a> { || meta .branches .iter() - .all(|b| self.claimed_names.contains(b.ref_name.as_ref())) + .all(|b| self.claimed_names.contains(b.as_ref())) { continue; } @@ -435,7 +616,7 @@ impl<'a> RunClaims<'a> { .iter() .find_map(|b| { self.pos_by_name - .get(b.ref_name.as_ref()) + .get(b.as_ref()) .filter(|(facts, _)| { facts.names_segment && !facts.names_empty_segment @@ -449,7 +630,7 @@ impl<'a> RunClaims<'a> { }); let Some(key) = key else { continue }; self.claimed_names - .extend(meta.branches.iter().map(|b| b.ref_name.as_ref())); + .extend(meta.branches.iter().map(|b| b.as_ref())); self.insert_claim( li, Run { @@ -474,7 +655,7 @@ impl<'a> RunClaims<'a> { if meta .branches .iter() - .all(|b| self.claimed_names.contains(b.ref_name.as_ref())) + .all(|b| self.claimed_names.contains(b.as_ref())) { continue; } @@ -487,12 +668,12 @@ impl<'a> RunClaims<'a> { || (on.is_some() && on == self.lower_bound) }; let all_empty_anchor = meta.branches.iter().find_map(|b| { - let &(facts, on) = self.pos_by_name.get(b.ref_name.as_ref())?; + let &(facts, on) = self.pos_by_name.get(b.as_ref())?; is_empty_pos(facts, on).then_some(on).flatten() }); let any_real = meta.branches.iter().any(|b| { self.pos_by_name - .get(b.ref_name.as_ref()) + .get(b.as_ref()) .is_some_and(|&(facts, on)| facts.names_segment && !is_empty_pos(facts, on)) }); let Some(anchor) = all_empty_anchor @@ -502,7 +683,7 @@ impl<'a> RunClaims<'a> { continue; }; self.claimed_names - .extend(meta.branches.iter().map(|b| b.ref_name.as_ref())); + .extend(meta.branches.iter().map(|b| b.as_ref())); self.insert_claim( li, Run { @@ -549,12 +730,8 @@ fn split_at_bound_empties_from_content_stacks( else { continue; }; - let rank_of = |name: &gix::refs::FullNameRef| { - chain - .branches - .iter() - .position(|b| b.ref_name.as_ref() == name) - }; + let rank_of = + |name: &gix::refs::FullNameRef| chain.branches.iter().position(|b| b.as_ref() == name); // The highest-ranked (closest to top) content-bearing member. let Some(top_content_rank) = stack .segments @@ -602,7 +779,7 @@ struct RunWalk<'a> { layout: &'a crate::ref_layout::RefLayout, idx: LayoutIndexes<'a>, chain_of_name: HashMap<&'a gix::refs::FullNameRef, usize>, - in_ws_stacks: Vec<&'a ref_metadata::WorkspaceStack>, + in_ws_stacks: Vec<&'a StackPartition>, owned_run_starts: HashSet, bound_run_exists: bool, anchor: ViewAnchor, @@ -718,14 +895,9 @@ impl<'a> RunWalk<'a> { || !seg.ref_name().is_some_and(in_gitbutler_namespace) }); if segments.is_empty() { - if li.is_some() { - self.emit_collapsed_chain_last_trace(&branch_names, &mut segments); - } - if segments.is_empty() { - // Fully empty stacks are removed โ€” a parent whose run was consumed - // elsewhere contributes nothing. - return None; - } + // Fully empty stacks are removed โ€” a parent whose run was consumed + // elsewhere contributes nothing. + return None; } Some(Stack::from_base_and_segments_raw(segments, id)) } @@ -749,8 +921,8 @@ impl<'a> RunWalk<'a> { claimed[li] = true; let mut names: Vec<&gix::refs::FullName> = Vec::new(); for b in &meta_stack.branches { - if !names.iter().any(|n| **n == b.ref_name) { - names.push(&b.ref_name); + if !names.iter().any(|n| **n == *b) { + names.push(b); } } bound_ids.insert(meta_stack.id); @@ -1072,7 +1244,13 @@ impl<'a> RunWalk<'a> { && self .chain_of_name .get(name.as_ref()) - .is_some_and(|&c| Some(c) != li && claimed[c]) => + .is_some_and(|&c| Some(c) != li && claimed[c]) + // Only where that other stack actually collects โ€” its own run's + // start. A foreign name MID-LEG (metadata written mid-move lists + // the branch in a chain that walks elsewhere) must not end the + // walk: nothing else collects these commits, so stopping would + // vanish them and everything below. + && self.owned_run_starts.contains(&id) => { SegmentCut::ForeignTerritory } @@ -1094,6 +1272,7 @@ impl<'a> RunWalk<'a> { ) { for n in pending.into_iter().chain(bound_rest) { if !sip.emitted.contains(n.as_ref()) { + sip.emitted.insert(n.as_ref()); // Empty leftovers own no commit โ€” None, not their anchor. sip.segments.push(named_segment(n.clone(), None, self.ctx)); } @@ -1139,31 +1318,45 @@ impl<'a> RunWalk<'a> { }) } - /// PHASE 6: a CHAIN-claimed run that collapsed to nothing (its walk broke at - /// the bound immediately, e.g. a branch merged upstream whose commit IS the - /// bound) still emits its empty/at-bound branches: this run is the chain's - /// only projection, and a vanished applied chain would be un-applied by the - /// next write. - fn emit_collapsed_chain_last_trace( + /// THE RECONCILIATION RULE: an applied branch projected nowhere traces as an empty + /// segment at its rest โ€” naming rights don't matter, the branch vanishes otherwise + /// and the next metadata write would silently unapply it โ€” but only when the rest + /// is INCORPORATED territory: integrated history, workspace territory no stack + /// displays (a rest on a displayed commit rides or splices there instead), or no + /// resting commit at all. Rests on unincorporated OUTSIDE commits stay out: they + /// are not-yet-merged content whose adoption is materialize's reconciliation work + /// (mid-operation projections feed the merges apply builds), never display fiat. + /// The workspace's own refs never rest as empties. + fn emit_leftover_traces( &self, - branch_names: &[&gix::refs::FullName], - segments: &mut Vec, + branch_names: &[&'a gix::refs::FullName], + sip: &mut SegmentsInProgress<'a>, + displayed_commits: &HashSet, ) { - for n in branch_names { - let pos = self.idx.pos_by_name.get(n.as_ref()); - // Naming rights don't matter here โ€” the chain vanishes otherwise, - // so any empty or at-bound position is trace enough. - let at_bound_or_empty = pos.is_some_and(|&(facts, on)| { - facts.names_empty_segment || (on.is_some() && on == self.lower_bound) - }); - if at_bound_or_empty { - let mut seg = named_segment((*n).clone(), None, self.ctx); - // The anchor is this empty branch's base โ€” operations - // rebuilding the workspace commit fall back to it when - // there is no tip. - seg.base = pos.and_then(|&(_, on)| on); - segments.push(seg); + for &n in branch_names { + if sip.emitted.contains(n.as_ref()) || in_gitbutler_namespace(n.as_ref()) { + continue; + } + let Some(&(_, on)) = self.idx.pos_by_name.get(n.as_ref()) else { + continue; + }; + let incorporated = match on { + None => true, + Some(on) => self.cg.node(on).is_none_or(|n| { + n.flags.contains(crate::CommitFlags::Integrated) + || (n.flags.contains(crate::CommitFlags::InWorkspace) + && !displayed_commits.contains(&on)) + }), + }; + if !incorporated { + continue; } + sip.emitted.insert(n.as_ref()); + let mut seg = named_segment(n.clone(), None, self.ctx); + // The rest is this empty branch's base โ€” operations rebuilding the + // workspace commit fall back to it when there is no tip. + seg.base = on; + sip.segments.push(seg); } } } @@ -1200,7 +1393,7 @@ fn prune_all_integrated_anonymous(stacks: &mut Vec) { /// a managed workspace commit defines the workspace view (a branch checkout /// inside a workspace); otherwise the entrypoint commit is the single run and /// the ad-hoc discriminator does the rest. -fn resolve_view_anchor( +pub(super) fn resolve_view_anchor( cg: &CommitGraph, layout: &crate::ref_layout::RefLayout, ws_meta: Option<&ref_metadata::Workspace>, @@ -1238,7 +1431,7 @@ fn entry_is_unapplied_branch( cg: &CommitGraph, ws_commit: gix::ObjectId, ws_meta: Option<&ref_metadata::Workspace>, - in_ws_stacks: &[&ref_metadata::WorkspaceStack], + in_ws_stacks: &[&StackPartition], has_managed_ref: bool, entry_ref: Option<&gix::refs::FullName>, ) -> bool { @@ -1262,19 +1455,19 @@ fn entry_is_unapplied_branch( /// The layout read three ways: positioned refs by name, run-naming refs by commit, /// empty-segment names, and out-of-workspace name projections. -struct LayoutIndexes<'a> { +pub(super) struct LayoutIndexes<'a> { pos_by_name: PosByName<'a>, - naming_at: HashMap, - names_empty: HashSet<&'a gix::refs::FullNameRef>, + pub(super) naming_at: HashMap, + pub(super) names_empty: HashSet<&'a gix::refs::FullNameRef>, /// A naming ref positioned OUTSIDE the workspace projects onto its first /// in-workspace first-parent ancestor: the segment there takes its name, and the /// outside prefix rides along as commits_outside. - projected_at: HashMap)>, + pub(super) projected_at: HashMap)>, } /// The `refs/heads/gitbutler/` namespace: refs GitButler creates for itself (the /// workspace ref, targets) rather than the user's branches. -fn in_gitbutler_namespace(name: &gix::refs::FullNameRef) -> bool { +pub(crate) fn in_gitbutler_namespace(name: &gix::refs::FullNameRef) -> bool { name.as_bstr().starts_with(b"refs/heads/gitbutler/") } @@ -1284,7 +1477,7 @@ fn is_implementation_ref(cg: &CommitGraph, name: &gix::refs::FullNameRef) -> boo in_gitbutler_namespace(name) && cg.entrypoint_ref().map(|r| r.as_ref()) != Some(name) } -fn index_layout<'a>( +pub(super) fn index_layout<'a>( cg: &CommitGraph, layout: &'a crate::ref_layout::RefLayout, anchor: ViewAnchor, @@ -1455,7 +1648,7 @@ fn next_parent( /// Refs consumed as structure stay off the commit: the segment's own naming ref, /// refs naming empty segments, remote-category refs, and gitbutler/* refs. -fn strip_structural_refs( +pub(super) fn strip_structural_refs( commit: &mut StackCommit, own_name: Option<&gix::refs::FullNameRef>, names_empty: &HashSet<&gix::refs::FullNameRef>, diff --git a/crates/but-graph/src/projection/frame.rs b/crates/but-graph/src/projection/frame.rs index 9b196e3eafb..ca6ba3b321b 100644 --- a/crates/but-graph/src/projection/frame.rs +++ b/crates/but-graph/src/projection/frame.rs @@ -8,8 +8,18 @@ use gix::ObjectId; use crate::workspace::{GraphContext, WorkspaceKind}; use crate::{CommitFlags, CommitGraph}; +/// What the entrypoint marks โ€” see [`WorkspaceFrame::entry_mark`]. +pub(crate) enum EntryMark<'a> { + /// The gate is closed (ad-hoc view or tip entry): nothing marks. + None, + /// Mark exactly the segments named this. + ByName(&'a gix::refs::FullNameRef), + /// Mark every segment containing this commit. + ByCommit(gix::ObjectId), +} + /// The commit/name-addressed workspace frame. -#[derive(Debug)] +#[derive(Debug, Clone)] pub(crate) struct WorkspaceFrame { /// What kind of workspace the entrypoint sees, `Managed`'s ref info included. pub kind: crate::workspace::WorkspaceKind, @@ -40,6 +50,41 @@ pub(crate) struct WorkspaceFrame { } impl WorkspaceFrame { + /// The entry FACTS every entrypoint semantic derives from, in the one authoritative + /// order: the entry ref's name, and the resolved entry commit (build-time resolution + /// first, the frame's remembered commit as fallback). Gate separately via + /// [`Workspace::is_entrypoint`](crate::Workspace::is_entrypoint) where marking applies. + pub(crate) fn entry_facts<'a>( + &'a self, + ctx: &GraphContext, + ) -> (Option<&'a gix::refs::FullNameRef>, Option) { + ( + self.entry_ref.as_ref().map(|r| r.as_ref()), + ctx.entry_resolved_commit.or(self.entry_commit), + ) + } + + /// What the entrypoint MARKS, in the one authoritative order: nothing unless the + /// mark gate holds (entry inside a managed workspace); the entry's NAME when it + /// names any segment (`names_a_segment` answers over the caller's substrate); + /// else every segment containing the entry's resolved commit. + pub(crate) fn entry_mark<'a>( + &'a self, + ctx: &GraphContext, + names_a_segment: impl FnOnce(&gix::refs::FullNameRef) -> bool, + ) -> EntryMark<'a> { + if !(self.entry_inside && self.kind.has_managed_ref()) { + return EntryMark::None; + } + let (ep_name, ep_commit) = self.entry_facts(ctx); + match ep_name { + Some(name) if names_a_segment(name) => EntryMark::ByName(name), + _ => ep_commit + .map(EntryMark::ByCommit) + .unwrap_or(EntryMark::None), + } + } + pub(crate) fn derive(cg: &CommitGraph, ctx: &GraphContext) -> Option { let naming = naming_commits(cg); let seeds = &cg.seeds; diff --git a/crates/but-graph/src/projection/init.rs b/crates/but-graph/src/projection/init.rs index d53e338207a..29346d0e9da 100644 --- a/crates/but-graph/src/projection/init.rs +++ b/crates/but-graph/src/projection/init.rs @@ -20,52 +20,35 @@ use crate::{ }, }; -/// Project the workspace from the commit graph and the build's context โ€” the -/// application's view over the substrate. +/// Reconcile the workspace SUBSTRATE from the commit graph and the build's context: the +/// frame facts, the reconciled [`view`](Workspace::view), and the carried graph โ€” +/// everything the application view needs EXCEPT the display projection. Stop here to hold a +/// fully-constructed workspace without pruning; [`Workspace::project`] is the optional final +/// layer that derives [`stacks`](Workspace::stacks) for the UI. /// -/// Every location of `HEAD` โ€” the entrypoint โ€” projects to SOME workspace. Expensive +/// Every location of `HEAD` โ€” the entrypoint โ€” reconciles to SOME workspace. Expensive /// per-commit reads are spent only on commits the user can interact with. /// /// Target commit ids and integrated traversal seeds can extend the /// workspace to include these commits to define its lowest base. -#[instrument(name = "project_workspace", level = "trace", skip(cg), err(Debug))] -pub(crate) fn project_workspace(cg: CommitGraph, ctx: GraphContext) -> anyhow::Result { +#[instrument(name = "reconcile_workspace", level = "trace", skip(cg), err(Debug))] +pub(crate) fn reconcile_workspace( + mut cg: CommitGraph, + ctx: GraphContext, +) -> anyhow::Result { let f = super::frame::WorkspaceFrame::derive(&cg, &ctx) .context("BUG: the graph must have an entrypoint seed to project a workspace")?; + // Anchor the integrated foundation STRICTLY BELOW the lower bound (the base's ancestors) + // so the rebase editor bounds its mutable set to the workspace's own commits. The base + // itself stays editable โ€” operations may reorder around it, and when the merge-base + // coincides with a workspace commit (e.g. `main`) the operation must be able to rewrite + // it. Seeding from the base's parents flags everything below without flagging the base. + let below_bound: Vec = f + .lower_bound + .and_then(|base| cg.node(base).map(|n| n.parent_ids.clone())) + .unwrap_or_default(); + cg.set_flag_on_ancestors(crate::CommitFlags::BelowBound, below_bound); let commits_below_tip = super::frame::commits_below_tip(&cg, &f); - let tip_ref_name = f.tip_ref_info.as_ref().map(|ri| ri.ref_name.clone()); - let stacks = { - let frame_entry_ref = f - .entry_inside - .then(|| f.entry_ref.clone()) - .flatten() - .or_else(|| tip_ref_name.clone()); - let frame_entry_commit = cg.entrypoint(); - let mut stacks = super::collect::stacks_from_arena( - &cg, - &ctx, - f.lower_bound, - f.metadata.as_ref(), - f.kind.has_managed_ref(), - frame_entry_ref.as_ref(), - frame_entry_commit, - ) - .unwrap_or_else(|| { - // Layoutless graphs โ€” unborn refs โ€” still name one empty stack. - tip_ref_name - .clone() - .map(|name| { - vec![Stack::from_base_and_segments_raw( - vec![super::collect::named_segment(name, None, &ctx)], - Some(StackId::single_branch_id()), - )] - }) - .unwrap_or_default() - }); - enrich_from_branch_details(&mut stacks, &ctx); - mark_entrypoint_segments(&mut stacks, &f, &ctx); - stacks - }; let mut target_ref = f.target_ref.as_ref().map(|t| TargetRef { ref_name: t.name.clone(), tip_commit_id: t.tip, @@ -121,58 +104,244 @@ pub(crate) fn project_workspace(cg: CommitGraph, ctx: GraphContext) -> anyhow::R first.is_some_and(|first| kinds.any(|kind| kind != first)) }; let target_commit = f.target_commit.map(|commit_id| TargetCommit { commit_id }); - let mut ws = Workspace { - kind: f.kind, - stacks, + let upstream_advanced_past_target = !f.integrated_tip_commits.is_empty(); + // The substrate is complete. The frame is retained VERBATIM as the derivation + // input โ€” the public fact fields below are copies for callers (legacy mutation + // included) and never feed the derivation. The reconciliation runs once, EAGERLY, + // reduced to the segment-graph fact; the display re-derives per call. + let segment_graph = + crate::workspace::SegmentGraph::from_stacks(&reconciled_stacks(&cg, &ctx, &f)); + Ok(crate::workspace::Workspace { + kind: f.kind.clone(), lower_bound: f.lower_bound, target_ref, target_commit, - metadata: f.metadata, - commit_graph: crate::CommitGraph::default(), + metadata: f.metadata.clone(), + segment_graph, + commit_graph: cg, ctx, - tip_ref_info: f.tip_ref_info, + tip_ref_info: f.tip_ref_info.clone(), tip_commit_id: f.tip_commit, - lower_bound_ref_name: f.lower_bound_ref_name, + lower_bound_ref_name: f.lower_bound_ref_name.clone(), entrypoint, target_ref_commit_id, effective_target_commit_id, commits_below_tip, incoming_target_commit_ids, + upstream_advanced_past_target, has_multiple_worktrees, - }; + frame: f, + }) +} - ws.prune_archived_segments(); - { - // Prune inputs at commit granularity: the advanced-upstream signal, the - // target's own top commit (the commit its ref really names, if any), and - // the tip's name for ad-hoc keeps. - let upstream_advanced_past_target = !f.integrated_tip_commits.is_empty(); - let target_top_anchor = ws - .target_commit - .as_ref() - .map(|tc| tc.commit_id) - .or_else(|| { - let name = &ws.target_ref.as_ref()?.ref_name; - let layout = cg.layout()?; - let facts = layout.facts_of(name.as_ref())?; - (facts.names_segment && !facts.names_empty_segment) - .then(|| layout.positioned_on(name.as_ref())) - .flatten() - }); - ws.prune_integrated_segments( - &cg, - upstream_advanced_past_target, - target_top_anchor, - tip_ref_name.as_ref().map(|r| r.as_ref()), +impl Workspace { + /// The pruned DISPLAY stacks, derived per call โ€” a fresh reconciliation run plus the + /// prune passes: archived, out-of-cone empties, integrated, remote reachability, and + /// the single-stack base truncation โ€” so display cost lives at the call site: bind it + /// where used. UI only; operations query the segment graph and never depend on the pruned + /// shape. + #[instrument(name = "project", level = "trace", skip(self), err(Debug))] + pub fn display_stacks(&self) -> anyhow::Result> { + let mut stacks = materialize_segment_graph( + &self.segment_graph, + &self.commit_graph, + &self.ctx, + &self.frame, ); + let cg = &self.commit_graph; + let tip_ref_name = self.tip_ref_info.as_ref().map(|ri| ri.ref_name.clone()); + self.prune_archived_segments(&mut stacks); + self.prune_out_of_cone_empties(&mut stacks, cg); + { + // Prune inputs at commit granularity: the advanced-upstream signal, the + // target's own top commit (the commit its ref really names, if any), and + // the tip's name for ad-hoc keeps. + let upstream_advanced_past_target = self.upstream_advanced_past_target; + let target_top_anchor = + self.target_commit + .as_ref() + .map(|tc| tc.commit_id) + .or_else(|| { + let name = &self.target_ref.as_ref()?.ref_name; + let layout = cg.layout()?; + let facts = layout.facts_of(name.as_ref())?; + (facts.names_segment && !facts.names_empty_segment) + .then(|| layout.positioned_on(name.as_ref())) + .flatten() + }); + self.prune_integrated_segments( + &mut stacks, + cg, + upstream_advanced_past_target, + target_top_anchor, + tip_ref_name.as_ref().map(|r| r.as_ref()), + ); + } + self.mark_remote_reachability(&mut stacks, cg)?; + self.add_commits_on_remote(&mut stacks, cg); + self.truncate_single_stack_to_match_base(&mut stacks); + debug_assert_applied_stacks_projected(cg, &stacks, self); + Ok(stacks) } - ws.mark_remote_reachability(&cg)?; - ws.add_commits_on_remote(&cg); - ws.truncate_single_stack_to_match_base(); - debug_assert_applied_stacks_projected(&cg, &ws); - // The projection is done: the substrate moves onto the workspace. - ws.commit_graph = cg; - Ok(ws) +} + +/// THE reconciliation core: derive the workspace's stacks from the layout the builder stored +/// on `cg` and the projection frame, plus the layoutless fallback (an unborn ref still names +/// one empty stack). This is the single authority for refโ†’stack membership, tip-ness, and +/// at-bound surfacing โ€” the facts every operation reads through the reconciled +/// [`view`](Workspace::view). The projection layers display framing (branch details, +/// entrypoint marks) on top; kept a free function of `(graph, context, frame)` so the +/// reconciliation can be exercised on its own. +/// Materialize display stacks FROM the stored segment graph: extents are given (tipโ†’boundary +/// walks), so no claiming runs โ€” only per-commit materialization and name-keyed +/// enrichment. This is the display twin of the eager reconciliation that produced the +/// segment graph; the shared output passes (enrichment, marks) keep the two coherent. +pub(super) fn materialize_segment_graph( + segment_graph: &crate::workspace::SegmentGraph, + cg: &CommitGraph, + ctx: &GraphContext, + f: &super::frame::WorkspaceFrame, +) -> Vec { + use super::collect; + let tip_ref_name = f.tip_ref_info.as_ref().map(|ri| ri.ref_name.clone()); + let frame_entry_ref = f + .entry_inside + .then(|| f.entry_ref.clone()) + .flatten() + .or_else(|| tip_ref_name.clone()); + let idx = cg.layout().and_then(|layout| { + let (ws_commit, _materialized) = collect::resolve_view_anchor( + cg, + layout, + f.metadata.as_ref(), + frame_entry_ref.as_ref(), + )?; + let anchor = collect::ViewAnchor::new( + cg.is_managed_ws_commit(ws_commit) && f.metadata.is_some(), + f.kind.has_managed_ref(), + ); + Some(collect::index_layout(cg, layout, anchor)) + }); + // A detached checkout hands its name back to the commit LAST, after tags. + let detached = cg.seeds.iter().any(|t| t.is_entrypoint && t.is_detached); + let mut stacks: Vec = segment_graph + .stacks + .iter() + .map(|skel_stack| { + let segments = skel_stack + .segments + .iter() + .map(|s| { + let mut seg = match &s.ref_name { + Some(name) => collect::named_segment(name.clone(), s.tip(), ctx), + None => StackSegment::default(), + }; + seg.name_projected_from_outside = s.name_projected_from_outside; + if s.name_projected_from_outside + && let Some(idx) = idx.as_ref() + && let Some(tip) = s.tip() + && let Some((_, outside)) = idx.projected_at.get(&tip) + { + seg.commits_outside = Some(outside.clone()); + } + let mut last_walked = None; + for &id in &s.commits { + let Some(node) = cg.node(id) else { break }; + let mut commit = StackCommit::from_graph_commit(node); + if let Some(idx) = idx.as_ref() { + collect::strip_structural_refs( + &mut commit, + seg.ref_name(), + &idx.names_empty, + ); + if detached + && let Some(&name) = idx.naming_at.get(&id) + && let Some(i) = commit + .refs + .iter() + .position(|ri| ri.ref_name.as_ref() == name.as_ref()) + { + let ri = commit.refs.remove(i); + commit.refs.push(ri); + } + } + seg.commits.push(commit); + last_walked = Some(id); + } + // A commit whose raw parents were never walked ends the extent early โ€” + // the traversal's limit, worn by the last collected commit. + if let Some(last) = last_walked + && cg.first_parent(last).is_none() + && let Some(node) = cg.node(last) + && !node.parent_ids.is_empty() + && !node.flags.contains(crate::CommitFlags::ShallowBoundary) + && let Some(commit) = seg.commits.last_mut() + { + commit.flags |= crate::workspace::StackCommitFlags::EarlyEnd; + } + seg.base = s.base; + seg + }) + .collect(); + Stack::from_base_and_segments_raw(segments, skel_stack.id) + }) + .collect(); + enrich_from_branch_details(&mut stacks, ctx); + mark_entrypoint_segments(&mut stacks, f, ctx); + stacks +} + +fn reconcile_membership( + cg: &CommitGraph, + ctx: &GraphContext, + f: &super::frame::WorkspaceFrame, +) -> Vec { + let tip_ref_name = f.tip_ref_info.as_ref().map(|ri| ri.ref_name.clone()); + let frame_entry_ref = f + .entry_inside + .then(|| f.entry_ref.clone()) + .flatten() + .or_else(|| tip_ref_name.clone()); + let frame_entry_commit = cg.entrypoint(); + super::collect::stacks_from_arena( + cg, + ctx, + f.lower_bound, + f.metadata.as_ref(), + f.kind.has_managed_ref(), + frame_entry_ref.as_ref(), + frame_entry_commit, + ) + .unwrap_or_else(|| { + // Layoutless graphs โ€” unborn refs โ€” still name one empty stack. + tip_ref_name + .map(|name| { + vec![Stack::from_base_and_segments_raw( + vec![super::collect::named_segment(name, None, ctx)], + Some(StackId::single_branch_id()), + )] + }) + .unwrap_or_default() + }) +} + +/// The reconciled stacks BOTH the display view and the mutating operations consume: the +/// [`reconcile_membership`] core plus the two marks operations read โ€” per-branch metadata +/// ([`enrich_from_branch_details`]) and the entrypoint marks ([`mark_entrypoint_segments`]). +/// Worktree info rides along in the enrichment but is display-only (no operation reads it); +/// pruning is a separate layer ([`Workspace::project`]). A free function of +/// `(graph, context, frame)` so the reconciliation can be exercised, and reused, on its own. +/// The reconciliation as the SEGMENT GRAPH reduces it: membership plus name-keyed enrichment. +/// Entrypoint marks are display decoration โ€” [`materialize_segment_graph`] applies them; the +/// segment graph stores none, so the eager reduction never computes them. +pub(super) fn reconciled_stacks( + cg: &CommitGraph, + ctx: &GraphContext, + f: &super::frame::WorkspaceFrame, +) -> Vec { + let mut stacks = reconcile_membership(cg, ctx, f); + enrich_from_branch_details(&mut stacks, ctx); + stacks } /// Name-keyed enrichment from the builder's capture: metadata sidebands and worktree @@ -197,45 +366,54 @@ fn mark_entrypoint_segments( f: &super::frame::WorkspaceFrame, ctx: &GraphContext, ) { - if !(f.entry_inside && f.kind.has_managed_ref()) { - return; - } - let ep_name = f.entry_ref.as_ref().map(|r| r.as_ref()); - let ep_commit = ctx.entry_resolved_commit.or(f.entry_commit); - let by_name: Vec<&mut StackSegment> = stacks - .iter_mut() - .flat_map(|s| &mut s.segments) - .filter(|seg| ep_name.is_some() && seg.ref_name() == ep_name) - .collect(); - if by_name.is_empty() { - for seg in stacks.iter_mut().flat_map(|s| &mut s.segments) { - if ep_commit.is_some_and(|id| seg.commits.iter().any(|c| c.id == id)) { + use super::frame::EntryMark; + let mark = f.entry_mark(ctx, |name| { + stacks + .iter() + .flat_map(|s| &s.segments) + .any(|seg| seg.ref_name() == Some(name)) + }); + match mark { + EntryMark::None => {} + EntryMark::ByName(name) => { + for seg in stacks + .iter_mut() + .flat_map(|s| &mut s.segments) + .filter(|seg| seg.ref_name() == Some(name)) + { seg.is_entrypoint = true; } } - } else { - for seg in by_name { - seg.is_entrypoint = true; + EntryMark::ByCommit(id) => { + for seg in stacks.iter_mut().flat_map(|s| &mut s.segments) { + if seg.commits.iter().any(|c| c.id == id) { + seg.is_entrypoint = true; + } + } } } } -/// Test-build tripwire for the most dangerous projection failure: an APPLIED metadata stack -/// silently disappearing from `stacks`. Operations rebuild the workspace merge and metadata -/// heads from the projection, so a vanished stack does not just render wrong โ€” it gets -/// PERSISTED by the next write. Scoped to managed workspaces; a stack counts only when at -/// least one non-archived branch of it actually names a segment in this graph. +/// Test-build tripwire: an APPLIED metadata stack silently disappearing from `stacks`. +/// The persistence danger is gone (the reconcile removal: no write reads the view back) and +/// the merge resolves named tips from metadata + the graph. Operations no longer read this +/// projection for structure either โ€” they resolve on the reconciled `view` (a raw-graph +/// resolution was proven impossible: tip-ness is irreducibly reconciled). So this is now a +/// DISPLAY-COMPLETENESS guard โ€” applied branches must stay visible to the UI โ€” that doubles +/// as a forward-guard against the projectionโ†’metadata anti-pattern returning. The +/// trace/keep-list machinery it guards is genuine display machinery, not operation +/// scaffolding: relaxing it would regress the UI, not operations. Scoped to managed +/// workspaces; a stack counts only when at least one non-archived branch of it actually +/// names a segment in this graph. #[cfg(debug_assertions)] -fn debug_assert_applied_stacks_projected(cg: &CommitGraph, ws: &Workspace) { - use but_core::ref_metadata::StackKind::Applied; +fn debug_assert_applied_stacks_projected(cg: &CommitGraph, stacks: &[Stack], ws: &Workspace) { if !matches!(ws.kind, WorkspaceKind::Managed { .. }) { return; } let Some(meta) = ws.metadata.as_ref() else { return; }; - let projected: std::collections::HashSet<_> = ws - .stacks + let projected: std::collections::HashSet<_> = stacks .iter() .flat_map(|s| s.segments.iter()) .filter_map(|s| s.ref_name().map(|r| r.to_owned())) @@ -257,57 +435,60 @@ fn debug_assert_applied_stacks_projected(cg: &CommitGraph, ws: &Workspace) { } queue.extend(cg.all_parent_ids(id)); } - let positions = cg.layout(); - for stack in meta.stacks(Applied) { - let represented_branches: Vec<_> = stack - .branches - .iter() - .filter(|b| !b.archived) - .filter(|b| { - let Some(facts) = positions.and_then(|layout| layout.facts_of(b.ref_name.as_ref())) - else { - return false; - }; - if !facts.names_segment { - return false; - } - let Some(on) = - positions.and_then(|layout| layout.positioned_on(b.ref_name.as_ref())) - else { - return false; + /// How the workspace incorporates a branch's resting commit. Classified + /// INTEGRATED before in-workspace: target-absorbed work is integrated whether or + /// not the workspace still spans it (an unpulled target tip is Integrated). + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum Territory { + InWorkspace, + Integrated, + Outside, + Unborn, + } + let layout = cg.layout(); + for stack in meta.stacks(but_core::ref_metadata::StackKind::Applied) { + for branch in stack.branches.iter().filter(|branch| !branch.archived) { + let name = branch.ref_name.as_ref(); + let facts = layout.and_then(|layout| layout.facts_of(name)); + let rest = layout.and_then(|layout| layout.positioned_on(name)); + let territory = match rest { + None => Territory::Unborn, + Some(on) => match cg.node(on) { + None => Territory::Outside, + Some(node) if node.flags.contains(crate::CommitFlags::Integrated) => { + Territory::Integrated + } + Some(_) if reachable.contains(&on) => Territory::InWorkspace, + Some(_) => Territory::Outside, + }, + }; + // Only a branch that NAMES a segment can be demanded of the view (a passive + // rider is representable only through its carrier). Live workspace content + // must always show; INTEGRATED territory away from the lower bound is + // deliberately not overzealously materialized โ€” an empty or a rest ON the + // bound keeps its stack. + let represented = facts.is_some_and(|facts| facts.names_segment) + && match territory { + Territory::InWorkspace => true, + Territory::Integrated => { + facts.is_some_and(|facts| facts.names_empty_segment) + || rest == ws.lower_bound + } + Territory::Outside | Territory::Unborn => false, }; - reachable.contains(&on) - // A stack anchored on INTEGRATED territory away from the workspace - // lower bound is deliberately not overzealously materialized; one - // resting ON the lower bound (or holding live commits, or empty) - // must keep its stack. - && (facts.names_empty_segment - || cg.node(on).is_none_or(|n| { - !n.flags.contains(crate::CommitFlags::Integrated) - || Some(on) == ws.lower_bound - })) - }) - .map(|b| b.ref_name.clone()) - .collect(); - if represented_branches.is_empty() { - continue; + debug_assert!( + !represented || projected.contains(name), + "applied branch {:?} of stack {:?} ({territory:?}) vanished from the projection โ€” \ + operations writing from this projection would drop it on disk", + name.as_bstr(), + stack.id, + ); } - debug_assert!( - represented_branches - .iter() - .any(|b| projected.contains(b.as_ref())), - "applied metadata stack {:?} (branches {:?}) vanished from the projection โ€” operations writing from this projection would drop the stack on disk", - stack.id, - represented_branches - .iter() - .map(|b| b.as_bstr().to_string()) - .collect::>(), - ); } } #[cfg(not(debug_assertions))] -fn debug_assert_applied_stacks_projected(_cg: &CommitGraph, _ws: &Workspace) {} +fn debug_assert_applied_stacks_projected(_cg: &CommitGraph, _stacks: &[Stack], _ws: &Workspace) {} impl Workspace { /// Prune segments whose branch is marked archived in workspace metadata โ€” matched by name, @@ -315,7 +496,34 @@ impl Workspace { /// a non-empty tail keeps the segment visible regardless of the flag. A stack whose every /// segment was archived is removed entirely. The flag deliberately stays out of the /// segment data itself so archived handling remains local to this pass. - fn prune_archived_segments(&mut self) { + /// Empty segments resting on target-only history โ€” integrated commits the workspace + /// tip cannot reach โ€” are not displayed: such a branch (typically integrated while + /// upstream advanced past the stored target) has left the workspace cone. The + /// reconciliation keeps it for totality, so operations still see and un-apply it. + fn prune_out_of_cone_empties(&self, stacks: &mut Vec, cg: &crate::CommitGraph) { + if self.target_ref.is_none() { + return; + } + for stack in stacks.iter_mut() { + stack.segments.retain(|segment| { + if !segment.commits.is_empty() { + return true; + } + let Some(anchor) = segment.base else { + return true; + }; + let Some(node) = cg.node(anchor) else { + return true; + }; + !(node.flags.contains(crate::CommitFlags::Integrated) + && !node.flags.contains(crate::CommitFlags::InWorkspace)) + }); + } + stacks.retain(|stack| !stack.segments.is_empty()); + } + + fn prune_archived_segments(&self, stacks: &mut Vec) { + // Explicit substrate path: keeps the borrow disjoint from `stacks` below. let Some(md) = &self.metadata else { return; }; @@ -327,11 +535,11 @@ impl Workspace { let mut empty_stacks_to_remove = Vec::new(); for archived_ref_name in archived_stack_branches { let Some((stack_idx, segment_idx)) = - find_segment_owner_indexes_by_refname(&self.stacks, archived_ref_name) + find_segment_owner_indexes_by_refname(&stacks, archived_ref_name) else { continue; }; - let stack = &mut self.stacks[stack_idx]; + let stack = &mut stacks[stack_idx]; let all_downwards_are_empty = stack.segments[segment_idx..] .iter() .all(|s| s.commits.is_empty()); @@ -346,7 +554,7 @@ impl Workspace { empty_stacks_to_remove.sort(); for stack_idx_to_remove in empty_stacks_to_remove.into_iter().rev() { - let stack = self.stacks.remove(stack_idx_to_remove); + let stack = stacks.remove(stack_idx_to_remove); tracing::warn!( "Pruned stack {stack_id:?} from workspace as all its segments were archived", stack_id = stack.id @@ -361,7 +569,8 @@ impl Workspace { // TODO: the per-stack fork point is recomputed on every projection rather than // stored; persisting it would avoid re-deriving the target trunk each build. fn prune_integrated_segments( - &mut self, + &self, + stacks: &mut Vec, cg: &crate::CommitGraph, upstream_advanced_past_target: bool, target_top_anchor: Option, @@ -390,6 +599,8 @@ impl Workspace { return; }; let prune_commits = prunable_territory(cg, target_top, upstream_advanced_past_target); + // Explicit substrate paths from here on: these borrows live across the + // `&mut stacks` loops below and must stay disjoint from them. let metadata = self.metadata.as_ref(); let keep_empty_names = if matches!(self.kind, WorkspaceKind::AdHoc) { self.ctx @@ -426,21 +637,46 @@ impl Workspace { .map(|local| local.as_ref()) }) .flatten(); - for stack in &mut self.stacks { + // A below-cut segment survives as a shell when its ref is ANY applied metadata + // branch โ€” a chain member can be absorbed into a sibling stack's walk, so the host + // stack's own list is not the authority. Archived branches are excluded, which + // compensates for `prune_archived_segments` running before integrated pruning: + // archived segments that still had commits are skipped there, then emptied here. + let applied_branch_names: std::collections::BTreeSet<&gix::refs::FullNameRef> = metadata + .map(|meta| { + meta.stacks(Applied) + .flat_map(|ms| { + ms.branches + .iter() + .filter(|b| !b.archived) + .map(|b| b.ref_name.as_ref()) + // Unreconciled legacy metadata can list the workspace ref + // itself as a branch; gitbutler/* never rests as a segment. + .filter(|rn| !super::collect::in_gitbutler_namespace(rn)) + }) + .collect() + }) + .unwrap_or_default(); + for stack in stacks.iter_mut() { let is_target_local_stack = stack.id.is_some() && stack.ref_name().is_some_and(|rn| Some(rn) == target_local); // Upstream advanced: floor the stack at its fork point but keep a fully-integrated // tip in managed workspaces so it survives for `integrate_upstream`. Single-branch // mode keeps the branch shell, but prunes integrated target/base commits from it. if !is_target_local_stack { - prune_integrated_stack_segments(stack, &prune_commits, keep_if_fully_integrated); + prune_integrated_stack_segments( + stack, + &prune_commits, + keep_if_fully_integrated, + &applied_branch_names, + ); } } - let orphan_chain_names = orphan_chain_names(metadata, &self.stacks); - for stack in &mut self.stacks { + let orphan_chain_names = orphan_chain_names(metadata, &stacks); + for stack in stacks.iter_mut() { remove_empty_branches( stack, - metadata, + &applied_branch_names, &keep_empty_names, &orphan_chain_names, matches!(self.kind, WorkspaceKind::AdHoc), @@ -450,16 +686,19 @@ impl Workspace { // own base. stack.recompute_last_segment_base_from(cg); } - self.stacks.retain(|stack| !stack.segments.is_empty()); - hide_bases_on_sibling_territory(&mut self.stacks); + stacks.retain(|stack| !stack.segments.is_empty()); + hide_bases_on_sibling_territory(stacks); } /// Trace each linked remote down the ARENA and set commit flags to indicate /// whether a commit in the workspace is reachable from a remote, and how. - fn mark_remote_reachability(&mut self, cg: &crate::CommitGraph) -> anyhow::Result<()> { + fn mark_remote_reachability( + &self, + stacks: &mut Vec, + cg: &crate::CommitGraph, + ) -> anyhow::Result<()> { // The builder records whether it actually LINKED a remote โ€” an ambiguous // remote stays deliberately unlinked, so a name lookup won't do. - let remote_refs: Vec<_> = self - .stacks + let remote_refs: Vec<_> = stacks .iter() .flat_map(|s| { s.segments.iter().filter_map(|s| { @@ -503,7 +742,7 @@ impl Workspace { } } for Flagging { at, remote } in flaggings { - for stack in &mut self.stacks { + for stack in stacks.iter_mut() { // The remote's reach into this stack starts where the commit appears. let Some((first_segment, first_commit_index)) = stack.segments.iter().enumerate().find_map(|(os_idx, os)| { @@ -541,10 +780,10 @@ impl Workspace { /// - non-integrated commits from upper stack segments that are still on the /// remote (the "branch split" case โ€” a previously combined push left the /// remote pointing at commits that now belong to branch above it). - fn add_commits_on_remote(&mut self, cg: &crate::CommitGraph) { + fn add_commits_on_remote(&self, stacks: &mut Vec, cg: &crate::CommitGraph) { let remote_named_at = remote_naming_positions(cg); let naming = naming_positions(cg); - for stack in &mut self.stacks { + for stack in stacks.iter_mut() { let mut above_commit_ids = HashSet::new(); for seg_idx in 0..stack.segments.len() { let Some(tip) = stack.segments[seg_idx] @@ -635,11 +874,11 @@ impl Workspace { /// If there is a single stack and the base happens to be itself (which happens if the stack is directly integrated/inline with the target), /// then empty all commits and segment-related metadata. - fn truncate_single_stack_to_match_base(&mut self) { - if self.stacks.len() != 1 { + fn truncate_single_stack_to_match_base(&self, stacks: &mut Vec) { + if stacks.len() != 1 { return; } - let Some(stack) = self.stacks.first_mut() else { + let Some(stack) = stacks.first_mut() else { return; }; let stack_is_base = @@ -671,6 +910,7 @@ fn prune_integrated_stack_segments( stack: &mut Stack, prune_commits: &HashMap, keep_if_fully_integrated: bool, + metadata_branch_names: &std::collections::BTreeSet<&gix::refs::FullNameRef>, ) { // Walk stack segments bottom-up, then prune-groups bottom-up within each stack // segment. Stop at the first group that is either not fully integrated or not @@ -722,7 +962,24 @@ fn prune_integrated_stack_segments( } else { cut_seg_idx + 1 }; - stack.segments.truncate(keep); + // Below the cut, metadata-tracked branches survive as empty shells โ€” their commits are + // integrated trunk, but the branches themselves are applied workspace state whose fate + // belongs to `remove_empty_branches` (dropping them here would silently unapply them + // on the next metadata write). Everything else โ€” trunk naming refs like the target's + // local โ€” goes with its territory. + let shells: Vec = stack + .segments + .drain(keep..) + .filter(|seg| { + seg.ref_name() + .is_some_and(|rn| metadata_branch_names.contains(rn)) + }) + .map(|mut seg| { + seg.commits.clear(); + seg + }) + .collect(); + stack.segments.extend(shells); } /// Remote-category refs that NAME territory, per commit: a commit reachable @@ -878,35 +1135,34 @@ fn hide_bases_on_sibling_territory(stacks: &mut [Stack]) { /// trace and always survive. fn remove_empty_branches( stack: &mut Stack, - metadata: Option<&but_core::ref_metadata::Workspace>, + applied_branch_names: &BTreeSet<&gix::refs::FullNameRef>, keep_empty_names: &BTreeSet<&gix::refs::FullNameRef>, orphan_chain_names: &HashSet<&gix::refs::FullNameRef>, keep_tip: bool, ) { - let own_metadata_stack = stack.id.and_then(|stack_id| { - metadata.and_then(|meta| meta.stacks(Applied).find(|ms| ms.id == stack_id)) - }); + let bottom_base = stack.segments.last().and_then(|seg| seg.base); let mut idx = 0; stack.segments.retain(|seg| { let is_tip = idx == 0; idx += 1; (keep_tip && is_tip) || !seg.commits.is_empty() - || seg - .ref_name() - .is_some_and(|rn| keep_empty_names.contains(rn) || orphan_chain_names.contains(rn)) - || own_metadata_stack.is_some_and(|ms| { - seg.ref_info - .as_ref() - // NOTE: `!b.archived` compensates for `prune_archived_segments` - // running *before* integrated-commit pruning โ€” archived segments - // that still had commits are skipped there, then emptied here. - // Once metadata is kept trimmed and up-to-date we can drop this. - .is_some_and(|ri| { - ms.branches - .iter() - .any(|b| b.ref_name == ri.ref_name && !b.archived) - }) + // Any applied (non-archived) metadata branch keeps its empty segment, + // WHEREVER it surfaced โ€” a chain member absorbed into a sibling stack's walk + // is still applied state, and dropping it would unapply it on the next + // metadata write. + || seg.ref_name().is_some_and(|rn| { + applied_branch_names.contains(rn) + || keep_empty_names.contains(rn) + || orphan_chain_names.contains(rn) }) }); + // Removing an empty bottom doesn't change what the stack rests on: carry the old + // bottom's base when the new bottom has none โ€” an all-empty stack has no commits for + // `recompute_last_segment_base_from` to derive it from. + if let Some(seg) = stack.segments.last_mut() + && seg.base.is_none() + { + seg.base = bottom_base; + } } diff --git a/crates/but-graph/src/projection/mod.rs b/crates/but-graph/src/projection/mod.rs index 37a78360c5b..d8b8ceb5fb3 100644 --- a/crates/but-graph/src/projection/mod.rs +++ b/crates/but-graph/src/projection/mod.rs @@ -16,7 +16,7 @@ mod collect; mod frame; mod init; pub use api::StackTip; -pub(crate) use init::project_workspace; +pub(crate) use init::reconcile_workspace; #[cfg(feature = "legacy")] pub use api::HeadStatus; @@ -106,13 +106,15 @@ pub(crate) struct EntrypointInfo { pub is_ws_tip: bool, } -/// A workspace reference is a list of [Stacks](Stack) projected over a [`CommitGraph`](crate::CommitGraph). +/// The workspace: the commit graph (with its stored ref layout), the build context, +/// and the frame โ€” everything the derivations run from โ€” plus the scalar facts +/// captured at build time. The reconciled partition is stored ONLY as its +/// [`segment_graph`](Self::segment_graph) reduction โ€” the operations' authority; materialized +/// stacks exist per [`display_stacks`](Self::display_stacks) call, for display alone. #[derive(Clone)] pub struct Workspace { /// Specify what kind of workspace this is. pub kind: WorkspaceKind, - /// One or more stacks that live in the workspace, in order of parents of the workspace commit if there are more than one. - pub stacks: Vec, /// The commit all workspace commits build on โ€” the boundary at the bottom beyond which /// nothing belongs to the workspace. It is the longest path to the first commit shared /// with the target across all stacks, or, without a target, the first commit all stacks @@ -140,10 +142,20 @@ pub struct Workspace { /// /// Do not use this data to understand the workspace. It's unreconciled metadata which may /// have nothing to do with the actual workspace. - /// To see that, look at [Self::stacks]. + /// To see that, look at the [`segment_graph`](Self::segment_graph) or + /// [`display_stacks`](Self::display_stacks). pub metadata: Option, - // โ”€โ”€ The carried substrate. โ”€โ”€ + // โ”€โ”€ The derivation inputs. โ”€โ”€ + /// The frame the workspace was built from, retained VERBATIM so the reconciliation + /// derives from the exact build-time inputs (the public `metadata`/`kind` copies may + /// be mutated by legacy callers; the derivation must not see that). + pub(crate) frame: frame::WorkspaceFrame, + /// The reconciled stack PARTITION as a fact: which refs form which stacks, in declared + /// order, at their full in-workspace extent โ€” reduced from the reconciliation at + /// construction like every other captured fact. The operations-facing shape the + /// membership queries are migrating onto. + pub segment_graph: SegmentGraph, /// The commit graph the workspace was projected from โ€” the commit-addressed /// substrate all queries answer from. pub(crate) commit_graph: crate::CommitGraph, @@ -172,11 +184,109 @@ pub struct Workspace { /// The target-side commits ahead of the workspace base, from the target tip downward. /// `None` without a resolved target ref. pub(crate) incoming_target_commit_ids: Option>, + /// Whether integrated tips exist โ€” the upstream advanced past the stored target. A frame + /// fact captured for [`Self::project`], which consumes it to prune integrated segments. + pub(crate) upstream_advanced_past_target: bool, /// Whether more than one unique worktree is referenced by the graph โ€” the commit debug /// renderer keys ownership markers off it. pub(crate) has_multiple_worktrees: bool, } +impl Workspace { + /// THE reconciliation, derived fresh from the carried inputs โ€” commit graph (with + /// its stored ref layout), build context, and the retained frame. The [`segment_graph`](Self::segment_graph) + /// is its build-time reduction (the stored partition authority); the display derives + /// from a fresh run per [`display_stacks`](Self::display_stacks) call. + pub fn derive_reconciled(&self) -> Vec { + init::reconciled_stacks(&self.commit_graph, &self.ctx, &self.frame) + } + + /// Replace the segment graph with one reduced from hand-built `stacks` โ€” for tests. + #[doc(hidden)] + pub fn set_segment_graph_from_stacks_for_testing(&mut self, stacks: &[Stack]) { + self.segment_graph = SegmentGraph::from_stacks(stacks); + } + + /// Re-read the world with in-memory `overlay` refs and metadata, yielding the + /// workspace as it would look after applying them โ€” the mutation-facing rebuild. + pub fn redo( + &self, + repo: &gix::Repository, + meta: &impl but_core::RefMetadata, + overlay: crate::walk::Overlay, + ) -> anyhow::Result { + let overlay_for_flip = overlay.clone(); + let (repo, meta, entrypoint) = overlay.into_parts(repo, meta); + let (seed, ref_name) = match entrypoint { + Some(t) => t, + None => { + let ep = self + .entrypoint + .as_ref() + .context("BUG: entrypoint must always be set")?; + let mut ref_name = ep.ref_name.clone(); + let seed = if let Some(name) = ref_name.as_ref() { + match repo.try_find_reference(name.as_ref())? { + Some(mut reference) => Some(reference.peel_to_id()?.detach()), + None => { + // The previous traversal may have had a named entrypoint, but + // this overlay can drop that ref. If so, don't carry a stale + // entrypoint_ref override into the new traversal; it would fail + // validation instead of re-traversing from the remembered commit. + ref_name = None; + None + } + } + } else { + None + }; + let seed = seed.or(ep.commit_id).context( + "BUG: entrypoint must either remember the original commit id or have a resolvable ref", + )?; + (seed, ref_name) + } + }; + // The same dispatch as `Workspace::from_tip`, with the overlay served from memory + // by the builders. + let (cg, ctx) = crate::CommitGraph::dispatch_tip( + repo.for_attach_only(), + seed, + ref_name, + meta.for_inner_only(), + self.ctx.project_meta.clone(), + self.ctx.options.clone(), + overlay_for_flip, + )?; + init::reconcile_workspace(cg, ctx) + } + + /// Preview `commit_graph` โ€” typically a not-yet-materialized rebase's mutated arena โ€” + /// with the rebase's pending ref edits and entrypoint served from `overlay`, yielding + /// the workspace as it would look once materialized. + /// + /// Falls back to an overlay rewalk ([`Self::redo`]) when the commit graph has nothing to + /// project: the entrypoint is unborn or points outside the graph. + pub fn preview_from_commit_graph( + &self, + commit_graph: crate::CommitGraph, + repo: &gix::Repository, + meta: &impl but_core::RefMetadata, + overlay: crate::walk::Overlay, + ) -> anyhow::Result { + match crate::workspace_from_commit_graph( + commit_graph, + repo, + meta, + self.ctx.project_meta.clone(), + self.ctx.options.clone(), + overlay.clone(), + )? { + Some(preview) => Ok(preview), + None => self.redo(repo, meta, overlay), + } + } +} + /// Construction: the workspace-level entry points, mirroring the graph traversals. These are /// what operations use โ€” the segment view is a build/projection detail, and nothing /// outside the crate can build one. (The fold: builder and projection fuse behind these.) @@ -187,11 +297,26 @@ impl Workspace { pub fn empty_ad_hoc_for_testing() -> Self { Workspace { kind: WorkspaceKind::AdHoc, - stacks: vec![], lower_bound: None, target_ref: None, target_commit: None, metadata: None, + // Deriving over the empty graph + frame yields no stacks โ€” lazily correct. + frame: frame::WorkspaceFrame { + kind: WorkspaceKind::AdHoc, + metadata: None, + tip_commit: None, + tip_ref_info: None, + entry_ref: None, + entry_commit: None, + entry_inside: false, + lower_bound: None, + lower_bound_ref_name: None, + target_ref: None, + target_commit: None, + integrated_tip_commits: Vec::new(), + }, + segment_graph: SegmentGraph::default(), commit_graph: crate::CommitGraph::default(), ctx: GraphContext::default(), tip_ref_info: None, @@ -202,6 +327,7 @@ impl Workspace { effective_target_commit_id: None, commits_below_tip: Vec::new(), incoming_target_commit_ids: None, + upstream_advanced_past_target: false, has_multiple_worktrees: false, } } @@ -214,7 +340,7 @@ impl Workspace { options: crate::walk::Options, ) -> anyhow::Result { let (cg, ctx) = crate::CommitGraph::from_head(repo, meta, project_meta, options)?; - project_workspace(cg, ctx) + init::reconcile_workspace(cg, ctx) } /// Build the workspace as seen from `tip` (a checkout or preview position), named by @@ -227,7 +353,7 @@ impl Workspace { options: crate::walk::Options, ) -> anyhow::Result { let (cg, ctx) = crate::CommitGraph::from_tip(tip, ref_name, meta, project_meta, options)?; - project_workspace(cg, ctx) + init::reconcile_workspace(cg, ctx) } /// Build the workspace from multiple `tips` at once โ€” the multi-tip variant of @@ -240,60 +366,7 @@ impl Workspace { options: crate::walk::Options, ) -> anyhow::Result { let (cg, ctx) = crate::CommitGraph::from_seeds(repo, tips, meta, project_meta, options)?; - project_workspace(cg, ctx) - } - - /// Re-read the world with in-memory `overlay` refs and metadata, yielding the workspace as - /// it would look after applying them. - pub fn redo( - &self, - repo: &gix::Repository, - meta: &impl but_core::RefMetadata, - overlay: crate::walk::Overlay, - ) -> anyhow::Result { - let overlay_for_flip = overlay.clone(); - let (repo, meta, entrypoint) = overlay.into_parts(repo, meta); - let (seed, ref_name) = match entrypoint { - Some(t) => t, - None => { - let ep = self - .entrypoint - .as_ref() - .context("BUG: entrypoint must always be set")?; - let mut ref_name = ep.ref_name.clone(); - let seed = if let Some(name) = ref_name.as_ref() { - match repo.try_find_reference(name.as_ref())? { - Some(mut reference) => Some(reference.peel_to_id()?.detach()), - None => { - // The previous traversal may have had a named entrypoint, but - // this overlay can drop that ref. If so, don't carry a stale - // entrypoint_ref override into the new traversal; it would fail - // validation instead of re-traversing from the remembered commit. - ref_name = None; - None - } - } - } else { - None - }; - let seed = seed.or(ep.commit_id).context( - "BUG: entrypoint must either remember the original commit id or have a resolvable ref", - )?; - (seed, ref_name) - } - }; - // The same dispatch as `from_tip`, with the overlay served from memory by - // the builders. - let (cg, ctx) = crate::CommitGraph::dispatch_tip( - repo.for_attach_only(), - seed, - ref_name, - meta.for_inner_only(), - self.ctx.project_meta.clone(), - self.ctx.options.clone(), - overlay_for_flip, - )?; - project_workspace(cg, ctx) + init::reconcile_workspace(cg, ctx) } /// Validate the carried graph's invariants (see `CommitGraph::validation_errors`), passing `self` @@ -453,7 +526,10 @@ pub(crate) fn upstream_commits( collected } -fn find_segment_owner_indexes_by_refname( +/// The `(stack_idx, segment_idx)` of the segment named `ref_name` in `stacks`, if any: the +/// refโ†’stack membership query as a free function over a stack slice, callable without a +/// whole [`Workspace`]. +pub fn find_segment_owner_indexes_by_refname( stacks: &[Stack], ref_name: &gix::refs::FullNameRef, ) -> Option<(usize, usize)> { @@ -469,3 +545,182 @@ fn find_segment_owner_indexes_by_refname( }) }) } + +/// The reconciled stack partition, reduced to what operations consume: per stack its +/// identity and ordered segments with their anchor commits โ€” no per-commit +/// materialization. Computed eagerly at construction; commit-level questions walk the +/// carried graph between a segment's [`tip`](Segment::tip) and +/// [`boundary`](Segment::boundary). +#[derive(Debug, Clone, Default, PartialEq)] +pub struct SegmentGraph { + /// The stacks, in workspace (parent) order. + pub stacks: Vec, +} + +/// One stack of the partition: identity plus its segments, tipโ†’base. +#[derive(Debug, Clone, PartialEq)] +pub struct SegmentStack { + /// The stable stack id, when declared. + pub id: Option, + /// The segments, topmost first. A self-contained node each โ€” the stack is only + /// the ordering container. + pub segments: Vec, + /// The commit this stack rests on, if any. + pub base: Option, +} + +/// One segment node: a named (or anonymous) run of commits, reduced to its anchors. +#[derive(Debug, Clone, PartialEq)] +pub struct Segment { + /// The branch naming this segment; `None` for anonymous runs. + pub ref_name: Option, + /// The commits the claiming assigned to this segment, tipโ†’bottom โ€” the extent as + /// the ONE authority decided it (a sibling stack's claim can cut it where no graph + /// walk could tell). Ids only: materialization stays display's job. + pub commits: Vec, + /// The segment's base as the reconciliation materialized it โ€” the commit below it + /// ALONG THE WALKED GRAPH, `None` when the walk was severed or hit a root. Display + /// range queries read this. + pub base: Option, + /// Whether the branch carries metadata (its sideband was found by name). + pub has_metadata: bool, + /// See [`StackSegment::name_projected_from_outside`]. + pub name_projected_from_outside: bool, +} + +impl Segment { + /// The segment's name, if not anonymous. + pub fn ref_name(&self) -> Option<&gix::refs::FullNameRef> { + self.ref_name.as_ref().map(|n| n.as_ref()) + } + + /// The segment's topmost own commit; `None` for empty segments. + pub fn tip(&self) -> Option { + self.commits.first().copied() + } +} + +impl SegmentGraph { + /// The stack carrying `id`, if any. `None` finds the first identityless stack. + pub fn stack_by_id( + &self, + id: impl Into>, + ) -> Option<&SegmentStack> { + let id = id.into(); + self.stacks.iter().find(|s| s.id == id) + } + + /// Every named segment's branch, across all stacks, in workspace order. + pub fn segment_names(&self) -> impl Iterator { + self.stacks.iter().flat_map(|s| s.segment_names()) + } + + /// The `(stack_idx, segment_idx)` of the segment named `name`, if any. + pub fn segment_location_by_name( + &self, + name: &gix::refs::FullNameRef, + ) -> Option<(usize, usize)> { + self.stacks + .iter() + .enumerate() + .find_map(|(stack_idx, stack)| { + stack + .segments + .iter() + .position(|s| s.ref_name() == Some(name)) + .map(|seg_idx| (stack_idx, seg_idx)) + }) + } +} + +impl SegmentStack { + /// The stack's name: its topmost segment's branch, if named. + pub fn ref_name(&self) -> Option<&gix::refs::FullNameRef> { + self.top().and_then(|s| s.ref_name()) + } + + /// The topmost segment, if any. + pub fn top(&self) -> Option<&Segment> { + self.segments.first() + } + + /// The first non-empty segment's tip โ€” the stack's resting commit above the base. + pub fn tip_skip_empty(&self) -> Option { + self.segments.iter().find_map(|s| s.tip()) + } + + /// The commit this stack RESTS on toward the workspace merge: its first non-empty + /// segment's tip, else its base. + pub fn resting_commit(&self) -> Option { + self.tip_skip_empty().or(self.base) + } + + /// The named segments' branches, topโ†’base. + pub fn segment_names(&self) -> impl Iterator { + self.segments.iter().filter_map(|s| s.ref_name()) + } + + /// The segments from `idx` toward the base โ€” a segment and its successors. + pub fn segments_at_or_below(&self, idx: usize) -> &[Segment] { + self.segments.get(idx..).unwrap_or(&[]) + } +} + +impl SegmentGraph { + /// Reduce materialized `stacks` โ€” the reconciliation output โ€” to the segment graph. + pub fn from_stacks(stacks: &[Stack]) -> Self { + SegmentGraph { + stacks: stacks + .iter() + .map(|stack| SegmentStack { + id: stack.id, + base: stack.base(), + segments: stack + .segments + .iter() + .map(|segment| Segment { + ref_name: segment.ref_name().map(ToOwned::to_owned), + commits: segment.commits.iter().map(|c| c.id).collect(), + base: segment.base, + has_metadata: segment.metadata.is_some(), + name_projected_from_outside: segment.name_projected_from_outside, + }) + .collect(), + }) + .collect(), + } + } +} + +/// The stack with `id` within `stacks`, if any โ€” bind [`Workspace::display_stacks`] (or the +/// reconciliation) at the call site and look up in it. +pub fn find_stack_by_id( + stacks: &[Stack], + id: impl Into>, +) -> Option<&Stack> { + let id = id.into(); + stacks.iter().find(|s| s.id == id) +} + +/// The commit `oid` within `stacks`, with its containing segment and stack. +pub fn find_commit_and_containers_in( + stacks: &[Stack], + oid: gix::ObjectId, +) -> Option<(&Stack, &StackSegment, &StackCommit)> { + stacks.iter().find_map(|stack| { + stack.segments.iter().find_map(|seg| { + seg.commits + .iter() + .find(|c| c.id == oid) + .map(|c| (stack, seg, c)) + }) + }) +} + +/// Whether `name` names the topmost segment of some stack in `stacks` (a stack TIP): the +/// tip-ness query as a free function over a stack slice, callable without a [`Workspace`]. +pub fn is_stack_tip(stacks: &[Stack], name: &gix::refs::FullNameRef) -> bool { + stacks + .iter() + .any(|stack| stack.segments.iter().find_map(|segment| segment.ref_name()) == Some(name)) +} diff --git a/crates/but-graph/src/records.rs b/crates/but-graph/src/records.rs index a13a670e92d..4ae643be3bf 100644 --- a/crates/but-graph/src/records.rs +++ b/crates/but-graph/src/records.rs @@ -197,6 +197,13 @@ bitflags! { const Integrated = 1 << 2; /// The commit is listed in the repository's shallow boundary file. const ShallowBoundary = 1 << 3; + /// Strictly BELOW the workspace lower bound โ€” the base's ancestors (NOT the base + /// itself). The rebase editor treats these as FIXED anchors (never rewrites them), + /// bounding its mutable set to the workspace's own commits. The base stays editable + /// so operations can reorder around it (e.g. moving a commit below `main` when the + /// merge-base IS `main`). Unlike the flags above (set at build time), this is set + /// during reconciliation, once the lower bound is known. + const BelowBound = 1 << 4; } } @@ -204,10 +211,12 @@ impl CommitFlags { /// Return a less verbose debug string. Goal bits are stripped at the arena /// boundary, so only the named flags render. pub fn debug_string(&self) -> String { - if self.is_empty() { + // BelowBound is an editor-bound concern set during reconciliation, not a walk fact; + // keep it out of the debug output so it doesn't clutter graph/projection snapshots. + let flags = *self & (Self::all() - Self::BelowBound); + if flags.is_empty() { "".into() } else { - let flags = *self & Self::all(); let string = format!("{flags:?}"); let out = &string["CommitFlags(".len()..]; out[..out.len() - 1] diff --git a/crates/but-graph/src/ref_layout.rs b/crates/but-graph/src/ref_layout.rs index b37125e0e61..0ee8ecf3888 100644 --- a/crates/but-graph/src/ref_layout.rs +++ b/crates/but-graph/src/ref_layout.rs @@ -103,17 +103,32 @@ /// How much of its commit's incoming edges a reference group carries โ€” which edges ENTER /// THROUGH the group's references. Generic over the id space: the graph stores commit ids, /// the rebase editor stores the same shape over its pick handles. +/// +/// # The aliasing rule +/// +/// `Edges` naming the commit's ENTIRE live edge set resolves to the same edges as `All`, +/// but the two are not interchangeable: `All` keeps carrying edges added later, an `Edges` +/// statement never grows. Every placement door normalizes toward `All` when the stated set +/// covers everything live โ€” the builder's `classify` at authoring, the editor's `place` on +/// every (re)placement โ€” so settled groups never hold a full-set `Edges` in disguise. +/// Group-merging comparisons ([`place_in_groups`], [`coalesce_groups`]) are SYNTACTIC over +/// those normalized statements: a same-(carry, attach) twin folds onto the existing +/// group's top rather than standing as an unordered position collision. What normalization +/// cannot settle โ€” a carry copied mid-surgery, before its pick's edges are final โ€” is the +/// mutation's own job to re-hang once edges settle, and anything that slips through is a +/// reported position collision at the editor's well-formedness checks, which compare by +/// RESOLUTION. #[derive(Debug, Clone, PartialEq, Eq)] pub enum GroupCarry { /// Nothing descends into this group (a root group: remote above a tip, empty top). None, /// Every edge into the commit descends through this group (a plain stack, or the shared - /// group all merge groups converge on). + /// group all merge groups converge on) โ€” including edges added AFTER this was stated. All, /// Exactly these stated edges โ€” one group of a merge. Kept sorted and deduplicated, /// keyed by the full `(child, parent number)` edge: two distinct sources can feed one /// commit at the same parent number (and one source at two parent numbers), so both coordinates are - /// needed. + /// needed. Never grows to cover edges added later. Edges(Vec<(Id, usize)>), } @@ -200,6 +215,33 @@ pub struct RefLayout { /// Where the empty chains rest: for each declared chain with no commits of its own, /// the commit its branches sit on, in metadata order. pub empty_chain_anchors: Vec, + /// The declared in-workspace stack partition, in metadata (workspace) order: each stack's + /// id and its branch names in declared tipโ†’base order, empty branches included. This is + /// `ws_meta`'s in-workspace stack list reduced to what the projection's claiming needs and + /// relocated onto the layout, giving the IN-WORKSPACE refโ†’stack partition one home the + /// projection reads instead of re-consulting metadata. Inactive/out-of-workspace stacks + /// are deliberately excluded โ€” the frame still reads those from `ws_meta` directly (e.g. + /// `bind_stack_identity`, `entry_is_unapplied_branch`), so membership truth is split by + /// design. It mirrors the in-workspace + /// [`WorkspaceStack`](but_core::ref_metadata::WorkspaceStack)s WITHOUT cross-stack dedup + /// (a branch listed in two stacks appears in both) โ€” within-stack duplicates stay the + /// claim's concern, exactly as when it read the metadata directly. + pub stacks: Vec, +} + +/// One in-workspace stack's declared partition: its stable id and its branch names in +/// declared tipโ†’base order (empties included). See [`RefLayout::stacks`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StackPartition { + /// The stable stack id, as declared in the workspace metadata. + pub id: but_core::ref_metadata::StackId, + /// The stack's branch names, tipโ†’base, exactly as the metadata lists them. + pub branches: Vec, + /// The declared parent edges as `(child_idx, parent_idx)` into [`branches`](Self::branches), + /// implied chain adjacency resolved โ€” the DAG declaration + /// ([`WorkspaceStackBranch::parents`](but_core::ref_metadata::WorkspaceStackBranch::parents)) + /// reduced for the claiming. A chain is `(i, i + 1)` pairs. + pub parent_edges: Vec<(usize, usize)>, } /// One reference's authoring input to [`RefLayout::from_parts`]: its facts, and โ€” when @@ -345,11 +387,29 @@ pub fn place_in_groups( carry: GroupCarry, attach: Option, ) { - let joined = attach.as_ref().and_then(|b| { - groups - .iter() - .position(|group| group.members.last() == Some(b) && group.carry == carry) - }); + let joined = attach + .as_ref() + .and_then(|b| { + groups + .iter() + .position(|group| group.members.last() == Some(b) && group.carry == carry) + }) + // A group already standing at this exact (carry, attach) key is this placement's + // home: a parallel twin would put two members at one rank โ€” the unordered position + // collision โ€” so the newcomer lands on the twin's top instead. Placement is the + // only door to the table, which makes the collision unconstructable rather than + // merely checked. `None` carries are exempt: they hold no rank (nothing enters + // through them), so independent root groups โ€” a dead ref's retained position, an + // empty top โ€” legitimately coexist and must not be folded together. + .or_else(|| { + (!matches!(carry, GroupCarry::None)) + .then(|| { + groups + .iter() + .position(|group| group.carry == carry && group.attach == attach) + }) + .flatten() + }); match joined { Some(g) => groups[g].members.push(name), None => groups.push(RefGroup { diff --git a/crates/but-graph/src/walk/walker.rs b/crates/but-graph/src/walk/walker.rs index b1a2a2bb7e6..18f2c9634e5 100644 --- a/crates/but-graph/src/walk/walker.rs +++ b/crates/but-graph/src/walk/walker.rs @@ -333,9 +333,11 @@ pub(crate) fn traverse( &mut seed_segments, &mut ep_seed, &next, - &initial_seeds.symbolic_remote_names, - &configured_remote_tracking_branches, - &initial_seeds.target_refs, + RemoteResolution { + symbolic_remote_names: &initial_seeds.symbolic_remote_names, + configured_tracking: &configured_remote_tracking_branches, + target_refs: &initial_seeds.target_refs, + }, meta, id, limit, @@ -597,7 +599,7 @@ struct PendingSeed { queue_front: bool, } -/// The target/local pairing ledger of the initial-seed queueing: a target seed must +/// The target/local pairing registry of the initial-seed queueing: a target seed must /// queue with its LOCAL partner's goal flag in its limit (so the target's walk can find /// the local), so a target arriving before its local PARKS here and is released when /// the local passes by โ€” before the local's own queue item when both point at the same @@ -639,7 +641,7 @@ impl PendingTargets<'_, '_> { } /// A target seed arrives: queue it right away when it has no local partner (no goal - /// to wait for) or when the partner's goal is already on the ledger โ€” park it + /// to wait for) or when the partner's goal is already recorded โ€” park it /// otherwise. fn park_or_queue( &mut self, @@ -660,7 +662,7 @@ impl PendingTargets<'_, '_> { } } - /// Release the target parked for `target_ref`, if any, with the ledger's goal flag. + /// Release the target parked for `target_ref`, if any, with the recorded goal flag. fn release( &mut self, next: &mut Queue, @@ -1027,6 +1029,17 @@ fn prune_integrated_tips(next: &mut Queue, ep_first_flags: Option) next.exhaust(); } +/// How to resolve and exclude remote-tracking branches during the walk: the symbolic remote +/// names to try, the configured remote-tracking set, and the workspace target refs (never +/// re-queued as their own remotes). Bundled so `target_refs` can't be transposed with the +/// commit's `refs` โ€” both were bare `&[FullName]`. +#[derive(Clone, Copy)] +struct RemoteResolution<'a> { + symbolic_remote_names: &'a [String], + configured_tracking: &'a std::collections::BTreeSet, + target_refs: &'a [gix::refs::FullName], +} + /// Queue the remote-tracking branches of newly seen local branches (lookup, goals, limits); /// the proxy segment becomes a seed record (its name is used for the double-queue check). #[allow(clippy::too_many_arguments)] @@ -1036,9 +1049,7 @@ fn try_queue_remote_tracking_branches( seed_segments: &mut Vec, ep_seed: &mut Option, next: &Queue, - target_symbolic_remote_names: &[String], - configured_remote_tracking_branches: &std::collections::BTreeSet, - target_refs: &[gix::refs::FullName], + remotes: RemoteResolution<'_>, meta: &OverlayMetadata<'_, T>, id: gix::ObjectId, limit: Limit, @@ -1056,13 +1067,13 @@ fn try_queue_remote_tracking_branches( super::remotes::lookup_remote_tracking_branch_or_deduce_it( repo, rn.as_ref(), - target_symbolic_remote_names, - configured_remote_tracking_branches, + remotes.symbolic_remote_names, + remotes.configured_tracking, )? else { continue; }; - if target_refs.contains(&remote_tracking_branch) { + if remotes.target_refs.contains(&remote_tracking_branch) { continue; } let Some(remote_tip) = diff --git a/crates/but-graph/tests/graph/walk/with_workspace.rs b/crates/but-graph/tests/graph/walk/with_workspace.rs index b8850c28423..8fe243ce669 100644 --- a/crates/but-graph/tests/graph/walk/with_workspace.rs +++ b/crates/but-graph/tests/graph/walk/with_workspace.rs @@ -309,9 +309,10 @@ fn no_overzealous_stacks_due_to_workspace_metadata() -> anyhow::Result<()> { snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 โ”œโ”€โ”€ โ‰ก:anon: on a821094 {2} -โ”‚ โ””โ”€โ”€ :anon: -โ”‚ โ”œโ”€โ”€ ยท835086d (๐Ÿ˜๏ธ) โ–บfour, โ–บthree -โ”‚ โ””โ”€โ”€ ยทff310d3 (๐Ÿ˜๏ธ) +โ”‚ โ”œโ”€โ”€ :anon: +โ”‚ โ”‚ โ”œโ”€โ”€ ยท835086d (๐Ÿ˜๏ธ) โ–บfour, โ–บthree +โ”‚ โ”‚ โ””โ”€โ”€ ยทff310d3 (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:feat-2 โ””โ”€โ”€ โ‰ก๐Ÿ“™:X <> origin/Xโ‡ก1 on 3183e43 {1} โ””โ”€โ”€ ๐Ÿ“™:X <> origin/Xโ‡ก1 โ”œโ”€โ”€ ยท0b203b5 (๐Ÿ˜๏ธ) @@ -633,6 +634,9 @@ layout: โ”‚ โ””โ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) โ–บtags/without-ref โ”œโ”€โ”€ ๐Ÿ“™:B-empty โ”‚ โ””โ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) โ–บambiguous-01 + โ”œโ”€โ”€ ๐Ÿ“™:A-empty-03 + โ”œโ”€โ”€ ๐Ÿ“™:A-empty-02 + โ”œโ”€โ”€ ๐Ÿ“™:A-empty-01 โ””โ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ โ„70bde6b (๐Ÿ˜๏ธ) @@ -676,6 +680,7 @@ layout: โ”‚ โ””โ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) โ–บtags/without-ref โ”œโ”€โ”€ ๐Ÿ“™:B-empty โ”‚ โ””โ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) โ–บambiguous-01 + โ”œโ”€โ”€ ๐Ÿ“™:A โ””โ”€โ”€ ๐Ÿ‘‰๐Ÿ“™:A-empty-01 โ””โ”€โ”€ โ„70bde6b (๐Ÿ˜๏ธ) โ–บA-empty-02, โ–บA-empty-03 @@ -706,6 +711,7 @@ layout: โ”‚ โ”‚ โ””โ”€โ”€ ยท320e105 (๐Ÿ˜๏ธ) โ–บtags/without-ref โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B-empty โ”‚ โ”‚ โ””โ”€โ”€ ยท2a31450 (๐Ÿ˜๏ธ) โ–บambiguous-01 +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A-empty-01 โ”‚ โ””โ”€โ”€ โ„70bde6b (๐Ÿ˜๏ธ) โ–บA-empty-02, โ–บA-empty-03 โ”œโ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:new-A on fafd9d0 {2} @@ -1229,8 +1235,8 @@ fn entrypoint_inside_second_parent_of_workspace_diamond_is_included() -> anyhow: "#]] ); - let entrypoint_stack_segment = ws - .stacks + let display_stacks = ws.display_stacks()?; + let entrypoint_stack_segment = display_stacks .iter() .flat_map(|stack| stack.segments.iter()) .find(|segment| segment.is_entrypoint) @@ -1514,15 +1520,15 @@ layout: snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:G <> โœ“refs/remotes/remote-0/H on fafd9d0 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on fafd9d0 {0} -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:C -โ””โ”€โ”€ โ‰ก๐Ÿ“™:D on fafd9d0 {1} - โ”œโ”€โ”€ ๐Ÿ“™:D - โ”œโ”€โ”€ ๐Ÿ“™:E - โ””โ”€โ”€ ๐Ÿ“™:F +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:A <> โœ“refs/remotes/remote-0/B on fafd9d0 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:C on fafd9d0 {0} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:C +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:D +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:E +โ””โ”€โ”€ โ‰ก๐Ÿ“™:F on fafd9d0 {1} + โ”œโ”€โ”€ ๐Ÿ“™:F + โ”œโ”€โ”€ ๐Ÿ“™:G + โ””โ”€โ”€ ๐Ÿ“™:H "#]] ); @@ -1970,6 +1976,7 @@ layout: โ”œโ”€โ”€ ๐Ÿ“™:G โ”œโ”€โ”€ ๐Ÿ“™:F โ”‚ โ””โ”€โ”€ ยท16f132b (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ ๐Ÿ“™:D โ””โ”€โ”€ ๐Ÿ“™:E โ””โ”€โ”€ ยท917b9da (๐Ÿ˜๏ธ) @@ -2010,6 +2017,7 @@ layout: โ”œโ”€โ”€ ๐Ÿ“™:G โ”œโ”€โ”€ ๐Ÿ“™:F โ”‚ โ””โ”€โ”€ ยท16f132b (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ ๐Ÿ“™:D โ””โ”€โ”€ ๐Ÿ“™:E โ””โ”€โ”€ ยท917b9da (๐Ÿ˜๏ธ) @@ -2760,9 +2768,10 @@ layout: snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ6 on 79bbb29 โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 79bbb29 {0} - โ””โ”€โ”€ ๐Ÿ“™:B - โ”œโ”€โ”€ ยท6b1a13b (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ยท03ad472 (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ ๐Ÿ“™:B + โ”‚ โ”œโ”€โ”€ ยท6b1a13b (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ ยท03ad472 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ๐Ÿ“™:A "#]] ); @@ -2803,9 +2812,10 @@ layout: snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ6 on 79bbb29 โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 79bbb29 {0} - โ””โ”€โ”€ ๐Ÿ“™:B - โ”œโ”€โ”€ ยท6b1a13b (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ยท03ad472 (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ ๐Ÿ“™:B + โ”‚ โ”œโ”€โ”€ ยท6b1a13b (๐Ÿ˜๏ธ) + โ”‚ โ””โ”€โ”€ ยท03ad472 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ๐Ÿ“™:A "#]] ); @@ -3267,6 +3277,7 @@ fn workspace_without_target_can_see_remote() -> anyhow::Result<()> { branches: vec![WorkspaceStackBranch { ref_name: ref_name.try_into()?, archived: false, + parents: None, }], workspacecommit_relation: WorkspaceCommitRelation::Merged, }); @@ -4177,7 +4188,8 @@ layout: snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ17 on 3183e43 โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 3183e43 {3} - โ””โ”€โ”€ ๐Ÿ“™:A + โ”œโ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -4934,9 +4946,11 @@ layout: snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 1818c17 โ””โ”€โ”€ โ‰ก๐Ÿ“™:B <> origin/Bโ‡ก1โ‡ฃ1 on 1818c17 {0} - โ””โ”€โ”€ ๐Ÿ“™:B <> origin/Bโ‡ก1โ‡ฃ1 - โ”œโ”€โ”€ ๐ŸŸฃe0bd0a7 - โ””โ”€โ”€ ยทda597e8 (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ ๐Ÿ“™:B <> origin/Bโ‡ก1โ‡ฃ1 + โ”‚ โ”œโ”€โ”€ ๐ŸŸฃe0bd0a7 + โ”‚ โ””โ”€โ”€ ยทda597e8 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ๐Ÿ“™:A <> origin/Aโ‡ฃ1 + โ””โ”€โ”€ ๐ŸŸฃ0b6b861 "#]] ); @@ -6326,16 +6340,18 @@ layout: graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ””โ”€โ”€ โ‰ก:anon: on bce0c5e {1} - โ”œโ”€โ”€ :anon: - โ”‚ โ””โ”€โ”€ ยทa7131b1 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ :intermediate-ref - โ”‚ โ”œโ”€โ”€ ยท4d3831e (๐Ÿ˜๏ธ) - โ”‚ โ”œโ”€โ”€ ยท468357f (๐Ÿ˜๏ธ) - โ”‚ โ”œโ”€โ”€ ยท118ddbb (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ ยท619d548 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยท8a352d5 (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก:anon: on bce0c5e {1} +โ”‚ โ”œโ”€โ”€ :anon: +โ”‚ โ”‚ โ””โ”€โ”€ ยทa7131b1 (๐Ÿ˜๏ธ) +โ”‚ โ”œโ”€โ”€ :intermediate-ref +โ”‚ โ”‚ โ”œโ”€โ”€ ยท4d3831e (๐Ÿ˜๏ธ) +โ”‚ โ”‚ โ”œโ”€โ”€ ยท468357f (๐Ÿ˜๏ธ) +โ”‚ โ”‚ โ”œโ”€โ”€ ยท118ddbb (๐Ÿ˜๏ธ) +โ”‚ โ”‚ โ””โ”€โ”€ ยท619d548 (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยท8a352d5 (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 6fdab32 {0} + โ””โ”€โ”€ ๐Ÿ“™:A "#]] ); @@ -6374,16 +6390,18 @@ layout: graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ””โ”€โ”€ โ‰ก:anon: on bce0c5e {1} - โ”œโ”€โ”€ :anon: - โ”‚ โ””โ”€โ”€ ยทa7131b1 (๐Ÿ˜๏ธ) - โ”œโ”€โ”€ :intermediate-ref - โ”‚ โ”œโ”€โ”€ ยท4d3831e (๐Ÿ˜๏ธ) - โ”‚ โ”œโ”€โ”€ ยท468357f (๐Ÿ˜๏ธ) - โ”‚ โ”œโ”€โ”€ ยท118ddbb (๐Ÿ˜๏ธ) - โ”‚ โ””โ”€โ”€ ยท619d548 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยท8a352d5 (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก:anon: on bce0c5e {1} +โ”‚ โ”œโ”€โ”€ :anon: +โ”‚ โ”‚ โ””โ”€โ”€ ยทa7131b1 (๐Ÿ˜๏ธ) +โ”‚ โ”œโ”€โ”€ :intermediate-ref +โ”‚ โ”‚ โ”œโ”€โ”€ ยท4d3831e (๐Ÿ˜๏ธ) +โ”‚ โ”‚ โ”œโ”€โ”€ ยท468357f (๐Ÿ˜๏ธ) +โ”‚ โ”‚ โ”œโ”€โ”€ ยท118ddbb (๐Ÿ˜๏ธ) +โ”‚ โ”‚ โ””โ”€โ”€ ยท619d548 (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยท8a352d5 (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 6fdab32 {0} + โ””โ”€โ”€ ๐Ÿ“™:A "#]] ); @@ -6807,7 +6825,7 @@ fn dependent_branch_on_base() -> anyhow::Result<()> { โ”œโ”€โ”€โ”€โ•ฏ * ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“) โ–บB, โ–บbelow-A, โ–บbelow-B, โ–บbelow-C, โ–บbelow-below-A, โ–บbelow-below-B, โ–บbelow-below-C, โ–บmain, โ–บorigin/main <> origin/main layout: - materialized parents: a0385a8: 49d4b34 3183e43 f9e2cb7 + materialized parents: a0385a8: 3183e43 f9e2cb7 49d4b34 empty chain anchors: 49d4b34^ 3183e43 f9e2cb7^ "#]] ); @@ -7336,7 +7354,7 @@ fn ambiguous_worktrees() -> anyhow::Result<()> { * ยท081bae9 (โŒ‚|๐Ÿ˜|โœ“) โ–บA, โ–บA-inside[๐Ÿ“wt-A-inside], โ–บA-outside[๐Ÿ“wt-A-outside] <> origin/A * ๐Ÿยท3183e43 (โŒ‚|๐Ÿ˜|โœ“) layout: - materialized parents: a5f94a2: 3e01e28 081bae9 + materialized parents: a5f94a2: 081bae9 3e01e28 empty chain anchors: 081bae9 "#]] ); @@ -7806,7 +7824,7 @@ fn reproduce_12146() -> anyhow::Result<()> { * ยท81d4e38 (โŒ‚|๐Ÿ˜) โ–บA * ๐Ÿยทe32cf47 (โŒ‚|๐Ÿ˜|โœ“) โ–บmain, โ–บorigin/main <> origin/main layout: - materialized parents: d77ecda: 7163661 81d4e38 + materialized parents: d77ecda: 81d4e38 7163661 empty chain anchors: 81d4e38^ 7163661^ "#]] ); diff --git a/crates/but-hunk-assignment/src/lib.rs b/crates/but-hunk-assignment/src/lib.rs index c4f24acb54c..f5581dc22c6 100644 --- a/crates/but-hunk-assignment/src/lib.rs +++ b/crates/but-hunk-assignment/src/lib.rs @@ -556,7 +556,8 @@ fn backfill_branch_ref_from_legacy_stack_id( && let Some(stack_id) = assignment.stack_id { assignment.branch_ref_bytes = workspace - .find_stack_by_id(stack_id) + .segment_graph + .stack_by_id(stack_id) .and_then(|stack| stack.ref_name()) .map(|ref_name| ref_name.to_owned()); } @@ -568,13 +569,10 @@ fn workspace_branches_by_stack( workspace: &but_graph::Workspace, ) -> HashMap> { let mut branches_by_stack = HashMap::new(); - for stack in &workspace.stacks { + for stack in &workspace.segment_graph.stacks { if let Some(id) = stack.id { - let branch_refs: Vec = stack - .segments - .iter() - .filter_map(|s| s.ref_name().map(|r| r.to_owned())) - .collect(); + let branch_refs: Vec = + stack.segment_names().map(ToOwned::to_owned).collect(); branches_by_stack.insert(id, branch_refs); } } @@ -740,7 +738,8 @@ fn requests_to_assignments( ), Some(HunkAssignmentTarget::Stack { stack_id }) => Some( workspace - .find_stack_by_id(stack_id) + .segment_graph + .stack_by_id(stack_id) .and_then(|stack| stack.ref_name()) .map(|ref_name| ref_name.to_owned()) .ok_or_else(|| { @@ -994,7 +993,7 @@ mod tests { fn workspace_with_stacks(stacks: Vec) -> Workspace { let mut ws = empty_workspace(); - ws.stacks = stacks; + ws.set_segment_graph_from_stacks_for_testing(&stacks); ws } diff --git a/crates/but-meta/src/legacy/mod.rs b/crates/but-meta/src/legacy/mod.rs index 00d00363369..b7d79a8991f 100644 --- a/crates/but-meta/src/legacy/mod.rs +++ b/crates/but-meta/src/legacy/mod.rs @@ -167,7 +167,8 @@ impl Snapshot { .filter(|workspace| workspace.kind.has_managed_commit()) .map(|workspace| { workspace - .stacks + .display_stacks() + .unwrap_or_default() .iter() .flat_map(|stack| stack.segments.iter()) .filter_map(|segment| { @@ -368,8 +369,8 @@ impl Snapshot { stack_id }; let original_stack_ids: Vec<_> = self.content.branches.keys().copied().collect(); - let ws_stacks_to_represent_in_vb_toml: Vec<_> = ws - .stacks + let ws_display_stacks = ws.display_stacks()?; + let ws_stacks_to_represent_in_vb_toml: Vec<_> = ws_display_stacks .iter() .enumerate() .map(|(idx, s)| { @@ -1319,6 +1320,7 @@ impl VirtualBranchesTomlMetadata { WorkspaceStackBranch { ref_name, archived: sb.archived, + parents: None, } }) }) diff --git a/crates/but-meta/tests/meta/ref_metadata_legacy.rs b/crates/but-meta/tests/meta/ref_metadata_legacy.rs index c22568e5832..75b2d02d46f 100644 --- a/crates/but-meta/tests/meta/ref_metadata_legacy.rs +++ b/crates/but-meta/tests/meta/ref_metadata_legacy.rs @@ -408,6 +408,7 @@ Workspace { branches: vec![WorkspaceStackBranch { ref_name: branch1.clone(), archived: false, + parents: None, }], }); ws_md.stacks.push(WorkspaceStack { @@ -416,6 +417,7 @@ Workspace { branches: vec![WorkspaceStackBranch { ref_name: branch2.clone(), archived: false, + parents: None, }], }); store.set_workspace(&ws_md)?; @@ -549,6 +551,7 @@ Workspace { branches: vec![WorkspaceStackBranch { ref_name: ref_name.try_into()?, archived: false, + parents: None, }], }); } @@ -722,6 +725,7 @@ fn create_workspace_and_stacks_with_branches_from_scratch() -> anyhow::Result<() branches: vec![WorkspaceStackBranch { ref_name: branch_name.clone(), archived: false, + parents: None, }], }); store @@ -765,6 +769,7 @@ fn create_workspace_and_stacks_with_branches_from_scratch() -> anyhow::Result<() WorkspaceStackBranch { ref_name: stacked_branch_name.clone(), archived: false, + parents: None, }, ); assert_eq!(ws.stacks[0].ref_name(), Some(&stacked_branch_name)); @@ -888,6 +893,7 @@ CommitId = "0000000000000000000000000000000000000000" WorkspaceStackBranch { ref_name: archived_branch.clone(), archived: true, + parents: None, }, ); store.set_workspace(&ws)?; @@ -949,6 +955,7 @@ CommitId = "0000000000000000000000000000000000000000" branches: vec![WorkspaceStackBranch { ref_name: branch.as_ref().into(), /* always a matching name */ archived: true, + parents: None, }], }); store.set_workspace(&ws)?; @@ -1010,6 +1017,7 @@ CommitId = "0000000000000000000000000000000000000000" branches: vec![WorkspaceStackBranch { ref_name: second_stack.clone(), archived: true, + parents: None, }], }); store.set_workspace(&ws)?; @@ -1210,14 +1218,17 @@ fn create_workspace_from_scratch_workspace_first() -> anyhow::Result<()> { WorkspaceStackBranch { ref_name: "refs/heads/top".try_into()?, archived: false, + parents: None, }, WorkspaceStackBranch { ref_name: "refs/heads/one-below-top".try_into()?, archived: true, + parents: None, }, WorkspaceStackBranch { ref_name: "refs/heads/base".try_into()?, archived: true, + parents: None, }, ], }); @@ -1227,6 +1238,7 @@ fn create_workspace_from_scratch_workspace_first() -> anyhow::Result<()> { branches: vec![WorkspaceStackBranch { ref_name: "refs/heads/second-branch".try_into()?, archived: false, + parents: None, }], }); @@ -2005,17 +2017,26 @@ fn removes_within_stack_duplicate_heads_even_when_mapped_to_a_segment_13345() -> store.write_reconciled(&repo)?; let store = VirtualBranchesTomlMetadata::from_path(path)?; - let shared_in_stack = store.data().branches.get(&stack_id).map(|stack| { - stack - .heads - .iter() - .filter(|head| head.name == "shared") - .count() - }); + for (id, stack) in store.data().branches.iter() { + let mut seen = std::collections::BTreeSet::new(); + for head in &stack.heads { + assert!( + seen.insert(head.name.as_str()), + "stack {id} must not keep the same branch twice, but repeats '{}'", + head.name + ); + } + } + let shared_across_stacks: usize = store + .data() + .branches + .values() + .flat_map(|stack| stack.heads.iter()) + .filter(|head| head.name == "shared") + .count(); assert_eq!( - shared_in_stack, - Some(1), - "the stack must not keep the same branch twice, even when it maps to a projected segment", + shared_across_stacks, 1, + "'shared' survives exactly once โ€” in whichever stack the projection homes it", ); Ok(()) diff --git a/crates/but-rebase/src/graph_rebase/cherry_pick.rs b/crates/but-rebase/src/graph_rebase/cherry_pick.rs index 3bb6901c70b..9cb0f9f0639 100644 --- a/crates/but-rebase/src/graph_rebase/cherry_pick.rs +++ b/crates/but-rebase/src/graph_rebase/cherry_pick.rs @@ -189,30 +189,38 @@ pub fn cherry_pick( repo, target, ontos, - base_tree, - onto_tree, - target_tree.detach(), + ConflictTrees { + base: base_tree, + ours: onto_tree, + theirs: target_tree.detach(), + }, sign_commit, ) } } } +/// The three input trees a conflict preserves, by role โ€” carried as one value so their +/// base/ours/theirs order can't be silently transposed across the merge helpers. +struct ConflictTrees { + base: gix::ObjectId, + ours: gix::ObjectId, + theirs: gix::ObjectId, +} + /// Merge `ours`/`theirs` over `base`, write the tree, and wrap the result as `target` /// replayed onto `ontos` โ€” conflicted or clean by `forced_resolution`. fn merge_into_outcome( repo: &gix::Repository, target: but_core::Commit<'_>, ontos: &[gix::ObjectId], - base: gix::ObjectId, - ours: gix::ObjectId, - theirs: gix::ObjectId, + trees: ConflictTrees, sign_commit: SignCommit, ) -> Result { let mut outcome = repo.merge_trees( - base, - ours, - theirs, + trees.base, + trees.ours, + trees.theirs, repo.default_merge_labels(), repo.merge_options_force_ours()?, )?; @@ -225,9 +233,7 @@ fn merge_into_outcome( tree_id, outcome, conflict_kind, - base, - ours, - theirs, + trees, sign_commit, )?; Ok(CherryPickOutcome::ConflictedCommit( @@ -300,9 +306,11 @@ fn maybe_materialize_conflicted_onto_merge( repo, target.clone(), ontos, - base_tree_id, - ours_tree_id, - theirs_tree_id, + ConflictTrees { + base: base_tree_id, + ours: ours_tree_id, + theirs: theirs_tree_id, + }, sign_commit, ) .map(Some) @@ -466,6 +474,13 @@ fn commit_from_unconflicted_tree<'repo>( { new_commit.extra_headers.remove(pos); } + // A pick can change the parent set, so any recorded parentโ†”stack binding is stale. + if let Some(pos) = new_commit + .extra_headers() + .find_pos(but_core::commit::HEADERS_WORKSPACE_PARENTS_FIELD) + { + new_commit.extra_headers.remove(pos); + } new_commit.parents = parents.into(); @@ -479,16 +494,13 @@ fn commit_from_unconflicted_tree<'repo>( .attach(repo)) } -#[expect(clippy::too_many_arguments)] fn commit_from_conflicted_tree<'repo>( parents: &[gix::ObjectId], mut to_rebase: but_core::Commit<'repo>, resolved_tree_id: gix::Id<'repo>, cherry_pick: gix::merge::tree::Outcome<'_>, treat_as_unresolved: gix::merge::tree::TreatAsUnresolved, - base_tree_id: gix::ObjectId, - ours_tree_id: gix::ObjectId, - theirs_tree_id: gix::ObjectId, + trees: ConflictTrees, sign_commit: SignCommit, ) -> anyhow::Result> { let repo = resolved_tree_id.repo; @@ -509,17 +521,17 @@ fn commit_from_conflicted_tree<'repo>( tree.upsert( TreeKind::Ours.as_tree_entry_name(), EntryKind::Tree, - ours_tree_id, + trees.ours, )?; tree.upsert( TreeKind::Theirs.as_tree_entry_name(), EntryKind::Tree, - theirs_tree_id, + trees.theirs, )?; tree.upsert( TreeKind::Base.as_tree_entry_name(), EntryKind::Tree, - base_tree_id, + trees.base, )?; tree.upsert( TreeKind::AutoResolution.as_tree_entry_name(), @@ -532,6 +544,14 @@ fn commit_from_conflicted_tree<'repo>( .headers() .unwrap_or_else(|| Headers::from_change_id(to_rebase.change_id())); headers.conflicted = None; + // A pick can change the parent set, so any recorded parentโ†”stack binding is stale. + if let Some(pos) = to_rebase + .inner + .extra_headers() + .find_pos(but_core::commit::HEADERS_WORKSPACE_PARENTS_FIELD) + { + to_rebase.inner.extra_headers.remove(pos); + } to_rebase.tree = tree.write().context("failed to write tree")?.detach(); to_rebase.parents = parents.into(); diff --git a/crates/but-rebase/src/graph_rebase/commit.rs b/crates/but-rebase/src/graph_rebase/commit.rs index 05cefa99a7c..105e95cc203 100644 --- a/crates/but-rebase/src/graph_rebase/commit.rs +++ b/crates/but-rebase/src/graph_rebase/commit.rs @@ -121,4 +121,19 @@ impl Editor<'_, M> { inner: obj, }) } + + /// Write an empty merge-commit object carrying `message` โ€” no parents yet; the + /// caller wires them. Authorship is kept (a merge synthesizes existing work). + /// The date mode is `CommitterKeepAuthorKeep`, not the `Update` that + /// [`Self::new_squashed_commit`] uses: the template here is a fresh + /// [`Self::empty_commit`] whose committer time is already now, whereas a squash + /// copies a stale existing commit and must refresh it. + pub fn new_merge_commit( + &self, + message: impl Into, + ) -> Result { + let mut commit = self.empty_commit()?; + commit.message = message.into(); + self.new_commit(commit, DateMode::CommitterKeepAuthorKeep) + } } diff --git a/crates/but-rebase/src/graph_rebase/creation.rs b/crates/but-rebase/src/graph_rebase/creation.rs index 475f3de0154..56c896a5003 100644 --- a/crates/but-rebase/src/graph_rebase/creation.rs +++ b/crates/but-rebase/src/graph_rebase/creation.rs @@ -132,10 +132,23 @@ fn build_graph_editor( } } - // Mutability follows reachability: the stored entrypoint reach, extended below by - // flooding from the extra mutable refs once the layout is ingested. - let mut mutable_commits: HashSet = - stored.reachable_commits.iter().copied().collect(); + // Mutability follows reachability, but stops at the workspace lower bound: the + // entrypoint's reach runs all the way to the root, so seeding mutability from it alone + // would make the target's whole ancestry rewritable and drag the rebase into re-creating + // ancient merges (whose already-resolved parents then re-conflict). Commits strictly + // below the base โ€” flagged BelowBound during reconciliation โ€” are fixed anchors instead; + // the base itself and everything above it (including integrated tips like origin/main + // when they ARE the merge-base) stay editable, so operations can reorder around them. + // The set is later extended below by flooding from the extra mutable refs once ingested. + let mut mutable_commits: HashSet = stored + .reachable_commits + .iter() + .copied() + .filter(|id| { + !cg.node(*id) + .is_some_and(|n| n.flags.contains(but_graph::CommitFlags::BelowBound)) + }) + .collect(); let mut step_graph = GraphEditor::adopt(arena); // Remote-category refs are never mutable โ€” the editor cannot move or delete the remote. @@ -276,7 +289,7 @@ fn build_graph_editor( }) .collect(); - crate::graph_rebase::positions::debug_assert_positions_total(&step_graph); + crate::graph_rebase::positions::assert_positions_total(&step_graph)?; Ok((step_graph, references, checkouts)) } diff --git a/crates/but-rebase/src/graph_rebase/graph_editor.rs b/crates/but-rebase/src/graph_rebase/graph_editor.rs index 247cfb134db..c4d3974767d 100644 --- a/crates/but-rebase/src/graph_rebase/graph_editor.rs +++ b/crates/but-rebase/src/graph_rebase/graph_editor.rs @@ -636,6 +636,14 @@ impl GraphEditor { /// All positioned references โ€” live AND dead โ€” ascending by id. pub(crate) fn positioned_refs(&self) -> impl Iterator + '_ { + // O(Rยฒ): locate() is a linear scan run once per ref, and the mutation helpers that + // loop this are O(Rยฒ)โ€“O(Rยณ). Fine while R is human-scale; the tripwire makes the + // "R stays small" assumption (see locate) announce itself before it becomes a cliff. + debug_assert!( + self.refs.len() < 4096, + "positioned_refs is O(Rยฒ); R={} โ€” de-linearize locate() before this scales", + self.refs.len() + ); (0..self.refs.len()) .map(RefIndex) .filter(|&entry| self.locate(entry).is_some()) @@ -643,6 +651,11 @@ impl GraphEditor { /// Where `entry` sits in the layout table: `(key, group index, member index)`. /// Membership is by NAME โ€” the record's current name is the table identity. + /// Find a reference's position as `(pick, group, member index)`. THE HOT PRIMITIVE: a + /// linear scan of the whole layout, deliberately left linear โ€” unlike the arena's + /// `children` index, which de-linearizes `incoming_edges` over thousands of commits โ€” + /// because R, the refs in a workspace, is human-scale. If a workspace ever grows into + /// the hundreds of branches, this is the primitive to index. fn locate(&self, entry: RefIndex) -> Option<(PickIndex, usize, usize)> { let name = self.name_of(entry); self.layout.iter().find_map(|(&key, groups)| { @@ -716,6 +729,7 @@ impl GraphEditor { carry: GroupCarry, attach: Option, ) { + let carry = self.normalize_carry(key, carry); let name = self.name_of(entry).clone(); let attach = attach.map(|b| self.name_of(b).clone()); but_graph::ref_layout::place_in_groups( @@ -726,12 +740,40 @@ impl GraphEditor { ); } + /// An `Edges` carry naming the key's ENTIRE live edge set re-states as `All` โ€” the same + /// normalization `classify` applies at authoring. Placement is the only door to the + /// table, so every live group holds a normalized carry and the twin-key rule in + /// [`but_graph::ref_layout::place_in_groups`] never misses a resolve-equal twin behind + /// a syntactically different statement. + fn normalize_carry(&self, key: PickIndex, carry: GroupCarry) -> GroupCarry { + let GroupCarry::Edges(edges) = &carry else { + return carry; + }; + let live = match crate::graph_rebase::positions::resolve_to_pick(self, key) { + Some(pick) => crate::graph_rebase::positions::edges_into(self, pick), + None => return carry, + }; + if !live.is_empty() && *edges == live { + GroupCarry::All + } else { + carry + } + } + fn coalesce_groups(&mut self, key: PickIndex) { if let Some(groups) = self.layout.get_mut(&key) { but_graph::ref_layout::coalesce_groups(groups); } } + /// The raw groups at `key`, for well-formedness failure reports. + pub(crate) fn groups_at_for_debug( + &self, + key: PickIndex, + ) -> Option<&Vec>> { + self.layout.get(&key) + } + fn set_ambiguous(&mut self, entry: RefIndex, ambiguous: bool) { self.refs[entry.0].ambiguous = ambiguous; } diff --git a/crates/but-rebase/src/graph_rebase/merge_commit_changes.rs b/crates/but-rebase/src/graph_rebase/merge_commit_changes.rs index 6efcee363bc..3701ae78ad6 100644 --- a/crates/but-rebase/src/graph_rebase/merge_commit_changes.rs +++ b/crates/but-rebase/src/graph_rebase/merge_commit_changes.rs @@ -6,6 +6,7 @@ use anyhow::{Context, Result, bail}; use but_core::{RefMetadata, commit::tree_expression::TreeExpression}; use gix::prelude::ObjectIdExt; +use crate::commit::DateMode; use crate::graph_rebase::{Editor, PickIndex, util::collect_ordered_parents}; /// A selected commit change range that should be merged into an accumulated @@ -103,6 +104,36 @@ impl Editor<'_, M> { }) } + /// Synthesize the squashed commit a merge outcome describes: `template`'s + /// identity with `parents`, the outcome's tree, and `message` โ€” the one way a + /// [`MergeCommitChangesOutcome`] becomes a commit. A conflicted outcome encodes + /// as a conflicted commit (the auto-resolved tree stays the visible tree, the + /// message gains conflict markers); a caller that refuses conflicts checks + /// [`MergeCommitChangesOutcome::conflict`] before calling. Committer dates + /// update, authorship stays. + pub fn new_squashed_commit( + &self, + mut template: but_core::CommitOwned, + parents: Vec, + outcome: MergeCommitChangesOutcome, + message: Vec, + ) -> Result { + template.inner.parents = parents.into(); + if let Some(conflict) = outcome.conflict { + template.tree = but_core::commit::write_conflicted_tree( + self.repo(), + outcome.tree_id, + &conflict.tree_expression, + &conflict.conflict_entries, + )?; + template.message = but_core::commit::add_conflict_markers(bstr::BStr::new(&message)); + } else { + template.tree = outcome.tree_id; + template.message = message.into(); + } + self.new_commit(template, DateMode::CommitterUpdateAuthorKeep) + } + /// Turn the selected commits into mergeable `base..commit` change ranges /// relative to a target commit. /// diff --git a/crates/but-rebase/src/graph_rebase/mutate.rs b/crates/but-rebase/src/graph_rebase/mutate.rs index e6e3abd73e1..bc45e289bf1 100644 --- a/crates/but-rebase/src/graph_rebase/mutate.rs +++ b/crates/but-rebase/src/graph_rebase/mutate.rs @@ -14,18 +14,18 @@ use but_core::RefMetadata; use serde::{Deserialize, Serialize}; use crate::graph_rebase::selector::{SelectorSet, SomeSelectors, StepRange}; -use crate::graph_rebase::{Editor, Selector, Step, ToSelector}; +use crate::graph_rebase::{Editor, Selector, Step, ToReferenceSelector, ToSelector}; /// Edge positions captured at one instant (a frame), looked up later against parent arrays /// that have since shifted. `current` maps a captured position to today's parent number; /// removals and inserts are noted so later lookups stay aligned. `None` means the named /// edge itself was already removed. #[derive(Default)] -struct NumberLedger { +struct NumberTranslation { map: HashMap>>, } -impl NumberLedger { +impl NumberTranslation { /// Today's parent number of the edge captured as `(source, frame_number)`, or `None` if it was /// removed. The identity map is built lazily, so a source must be looked up before any /// of its parent numbers mutate. @@ -131,6 +131,16 @@ pub enum InsertSide { #[cfg(feature = "export-schema")] but_schemars::register_sdk_type!(InsertSide); +/// What happens to the edges a disconnect severs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Reconnect { + /// Rewire the disconnected children onto the disconnected parents, healing the + /// graph around the removed range. + Heal, + /// Leave children and parents apart; the caller reconnects them itself. + Skip, +} + /// Controls where reparented insertion-location parents are ordered relative to /// existing parents on the range. #[derive(Debug, Clone)] @@ -142,6 +152,14 @@ pub enum ParentReparentingOrder { Append, } +/// The two `RefIndex` lists the child reconnect re-places: the ref children that ride the +/// range onto the pick, and the carried parent tops their stacks land above. Bundled so the +/// two same-typed slices can't be transposed at the call. +struct ReconnectRefs<'a> { + moving_ref_children: &'a [RefIndex], + carried_parent_tops: &'a [RefIndex], +} + /// Operations for mutating the editor graph impl Editor<'_, M> { /// Get a selector to a particular commit in the graph @@ -184,11 +202,27 @@ impl Editor<'_, M> { None } + /// Debug builds verify well-formedness at every public mutation exit, so an ill-formed + /// graph is caught at the mutation that produced it rather than at the next rebase. + /// Release builds rely on the `rebase()`/creation boundary checks. + fn verified(&self, out: Result) -> Result { + #[cfg(debug_assertions)] + if out.is_ok() { + crate::graph_rebase::positions::assert_positions_total(&self.graph)?; + } + out + } + /// Replace the step at `target`, returning the old one. Replacing a pick with another /// pick records a commit mapping from the old to the new id. Replacement stays within /// its namespace: a pick can become a pick or a tombstone, a reference can be renamed /// or deleted โ€” never one into the other. pub fn replace(&mut self, target: impl ToSelector, step: Step) -> Result { + let out = self.replace_impl(target, step); + self.verified(out) + } + + fn replace_impl(&mut self, target: impl ToSelector, step: Step) -> Result { let target = target.to_selector(self)?; let old = self.graph.step_view(target.id); if let (Step::Pick(from), Step::Pick(to)) = (&old, &step) @@ -226,23 +260,79 @@ impl Editor<'_, M> { Ok(old) } + /// Disconnect a range from ALL of its surroundings, rewiring the disconnected + /// children onto the disconnected parents. The common form of + /// [`Self::disconnect_range_from`]. Internal: the only callers are `remove_commit` + /// and `move_reference`; kept `pub(crate)` rather than exposed on the public surface. + pub(crate) fn disconnect_range(&mut self, target: StepRange) -> Result<()> + where + C: ToSelector, + P: ToSelector, + { + self.disconnect_range_from(target, SelectorSet::All, SelectorSet::All, Reconnect::Heal) + } + + /// Remove a commit node: the graph heals around it (its children reconnect to its + /// parents) and its step is nulled. The commit leaves the rebuild entirely โ€” used + /// where its content already lives elsewhere (squashed into another commit) or is + /// being discarded. + /// + /// `remove_commit` deletes a commit; [`Self::detach`] severs a link between two + /// commits that both survive. + pub fn remove_commit(&mut self, node: impl ToSelector) -> Result<()> { + let node = node.to_selector(self)?; + self.disconnect_range(StepRange { + child: node, + parent: node, + })?; + self.replace(node, Step::None)?; + Ok(()) + } + /// Disconnect a range (child and parent may be the same entry) from its surroundings: /// children from `target.child`, parents from `target.parent`. `SelectorSet::All` /// disconnects everything; a `Some` set must name direct children/parents or this - /// errors. Disconnected children are reconnected to the disconnected parents unless - /// `skip_reconnect_step` is set โ€” which is also the only way `parents_to_disconnect` - /// may be `SelectorSet::None`. + /// errors. [`Reconnect::Heal`] rewires the disconnected children onto the disconnected + /// parents; [`Reconnect::Skip`] leaves them apart โ€” which is also the only mode where + /// `parents_to_disconnect` may be `SelectorSet::None`. pub fn disconnect_range_from( &mut self, target: StepRange, children_to_disconnect: SelectorSet, parents_to_disconnect: SelectorSet, - skip_reconnect_step: bool, + reconnect: Reconnect, ) -> Result<()> where C: ToSelector, P: ToSelector, { + let out = self.disconnect_range_from_impl( + target, + children_to_disconnect, + parents_to_disconnect, + reconnect, + ); + self.verified(out) + } + + // The complexity epicenter of the editor. Correctness here rests on STATEMENT ORDERING + // that is documented but not type-enforced ("connect BEFORE the range gains its own + // downward edge", "capture edges BEFORE adding the new edge"): reorder two statements and + // the carry/position corruption is caught only by the debug per-mutation assert or, late, + // at the rebase boundary โ€” never at the offending edit. The long-term hardening is typed + // phase-result structs carrying that ordering instead of threaded Vec/Option args. + fn disconnect_range_from_impl( + &mut self, + target: StepRange, + children_to_disconnect: SelectorSet, + parents_to_disconnect: SelectorSet, + reconnect: Reconnect, + ) -> Result<()> + where + C: ToSelector, + P: ToSelector, + { + let skip_reconnect_step = matches!(reconnect, Reconnect::Skip); let StepRange { child, parent } = target; let mut target_child = child.to_selector(self)?; let mut target_parent = parent.to_selector(self)?; @@ -298,7 +388,7 @@ impl Editor<'_, M> { Some(Vec::new()) } else { return Err(anyhow!( - "Invalid parents to disconnect: SelectorSet::None is not allowed" + "Invalid parents to disconnect: SelectorSet::None requires Reconnect::Skip" )); } } @@ -405,15 +495,15 @@ impl Editor<'_, M> { .collect::>() }); - // One ledger spans both disconnect phases: the overlap case (the range's parent-most + // One translation spans both disconnect phases: the overlap case (the range's parent-most // sitting directly above the child-most's pick) captures the same edge in both frames. - let mut ledger = NumberLedger::default(); + let mut numbers = NumberTranslation::default(); // 2. Disconnect parents. let (disconnected_parent_edges, carried_parent_tops) = self.disconnect_range_parents( parent_node, outgoing_edges, parent_ids_to_disconnect.as_ref(), - &mut ledger, + &mut numbers, )?; // 3. Disconnect children and reconnect to the disconnected parents. self.disconnect_range_children( @@ -422,10 +512,12 @@ impl Editor<'_, M> { child_ids_to_disconnect.as_ref(), child_ref_edges.as_ref(), child_ref_depth, - &moving_ref_children, - &carried_parent_tops, + ReconnectRefs { + moving_ref_children: &moving_ref_children, + carried_parent_tops: &carried_parent_tops, + }, &disconnected_parent_edges, - &mut ledger, + &mut numbers, skip_reconnect_step, ); // 4. Re-adopt references left dangling on the range. @@ -445,7 +537,7 @@ impl Editor<'_, M> { parent_node: PickIndex, outgoing_edges: Vec<(usize, PickIndex)>, parent_ids_to_disconnect: Option<&HashSet>, - ledger: &mut NumberLedger, + numbers: &mut NumberTranslation, ) -> Result<(Vec<(usize, PickIndex)>, Vec)> { let mut disconnected_parent_edges: Vec<(usize, PickIndex)> = Vec::new(); let mut carried_parent_tops: Vec = Vec::new(); @@ -453,10 +545,10 @@ impl Editor<'_, M> { let should_disconnect = parent_ids_to_disconnect.is_none_or(|ids| ids.contains(&edge_target)); if should_disconnect { - // The ledger resolves the captured name to today's parent number (earlier removals + // The translation resolves the captured name to today's parent number (earlier removals // shift edges down). Shifts preserve relative order, so the recorded parent numbers // still sort the disconnected parents as their captured orders did. - let parent_number = ledger + let parent_number = numbers .current(&self.graph, parent_node, frame_number) .context("BUG: disconnected parent edge vanished")?; let removed = (parent_node, parent_number); @@ -483,7 +575,7 @@ impl Editor<'_, M> { carried_parent_tops.push(top); } self.graph.remove_parent(parent_node, parent_number); - ledger.note_remove(parent_node, parent_number); + numbers.note_remove(parent_node, parent_number); disconnected_parent_edges.push((parent_number, edge_target)); } } @@ -495,7 +587,7 @@ impl Editor<'_, M> { /// `skip_reconnect_step`), then move the groups that rode the range onto the first /// disconnected parent. A `Some` `child_ref_edges` means the range's child bound was a /// reference and holds the edges entering its group. - #[allow(clippy::too_many_arguments)] + #[expect(clippy::too_many_arguments)] fn disconnect_range_children( &mut self, child_node: PickIndex, @@ -503,10 +595,9 @@ impl Editor<'_, M> { child_ids_to_disconnect: Option<&HashSet>, child_ref_edges: Option<&Vec<(PickIndex, usize)>>, child_ref_depth: Option, - moving_ref_children: &[RefIndex], - carried_parent_tops: &[RefIndex], + reconnect: ReconnectRefs<'_>, disconnected_parent_edges: &[(usize, PickIndex)], - ledger: &mut NumberLedger, + numbers: &mut NumberTranslation, skip_reconnect_step: bool, ) { let full_child_disconnect = child_ids_to_disconnect.is_none(); @@ -520,9 +611,10 @@ impl Editor<'_, M> { if !should_disconnect { continue; } - // Earlier removals on the same child shift this edge down; the ledger resolves the + // Earlier removals on the same child shift this edge down; the translation resolves the // captured name to the parent number the store uses now. - let Some(parent_number) = ledger.current(&self.graph, edge_source, frame_number) else { + let Some(parent_number) = numbers.current(&self.graph, edge_source, frame_number) + else { // The parent loop already removed this edge: the range bounds can name // overlapping edges when the range's parent-most sits directly above // the child-most's pick. Only the reconnect still applies. @@ -553,14 +645,14 @@ impl Editor<'_, M> { for (offset, target) in targets.enumerate() { self.graph .insert_parent(edge_source, parent_number + 1 + offset, target); - ledger.note_insert(edge_source, parent_number + 1 + offset); + numbers.note_insert(edge_source, parent_number + 1 + offset); } continue; } // Remove the child edge; groups it carried lose it from their derived // entering set automatically. self.graph.remove_parent(edge_source, parent_number); - ledger.note_remove(edge_source, parent_number); + numbers.note_remove(edge_source, parent_number); if skip_reconnect_step { continue; } @@ -572,7 +664,7 @@ impl Editor<'_, M> { // reference child bound means the range INCLUDES that reference and its group // at or below its rank โ€” those stay with the range. if let Some(pick) = group_pick { - for moving_node in moving_ref_children { + for moving_node in reconnect.moving_ref_children { transfer_stack(&mut self.graph, *moving_node, child_node, pick); } } @@ -585,7 +677,7 @@ impl Editor<'_, M> { // group; step 3's reconnect added fresh edges into `pick`. The combined // stack now sits behind all of those fresh edges (`GroupCarry::All`), which // is also right when `pick` is a merge. - let landed = carried_parent_tops.first().is_some_and(|&top| { + let landed = reconnect.carried_parent_tops.first().is_some_and(|&top| { land_stack_above(&mut self.graph, child_node, top, pick) }); if !landed { @@ -671,6 +763,28 @@ impl Editor<'_, M> { nodes_to_connect: Option, parent_reparenting_order: ParentReparentingOrder, ) -> Result<()> + where + C: ToSelector, + P: ToSelector, + { + let out = self.insert_range_into_impl( + target, + range, + side, + nodes_to_connect, + parent_reparenting_order, + ); + self.verified(out) + } + + fn insert_range_into_impl( + &mut self, + target: impl ToSelector, + range: StepRange, + side: InsertSide, + nodes_to_connect: Option, + parent_reparenting_order: ParentReparentingOrder, + ) -> Result<()> where C: ToSelector, P: ToSelector, @@ -760,9 +874,29 @@ impl Editor<'_, M> { SplitBoundary::Above, child_pick, ); - if !split.moved_any { - // The group's edges now enter through the range's child-most pick. - redirect_edges(&mut self.graph, &target_edges, pick, child_pick); + // The group's edges now enter through the range's child-most pick โ€” directly + // when the target topped its group, or through the members that rode above the + // range (they carry their edge statements verbatim, so the arena edges must + // follow them or the range is silently bypassed). + redirect_edges(&mut self.graph, &target_edges, pick, child_pick); + // The moved slice lands on the child-most pick, which may already host the + // range's own ref chain: hang the slice on that chain's top so both form one + // stack. This runs AFTER the redirect โ€” the slice's carries were copied + // mid-surgery against stale edges, so only now can the placement fold them + // onto a resolve-equal chain instead of leaving colliding twins. + if let Some(boundary) = split.boundary { + let top_below = self + .graph + .positioned_refs() + .filter(|&entry| self.graph.is_reference(entry)) + .filter(|entry| !split.moved.contains(entry)) + .filter(|&entry| { + positions::resolve_to_pick(&self.graph, entry) == Some(child_pick) + }) + .max_by_key(|&entry| positions::ref_depth(&self.graph, entry)); + if let Some(top) = top_below { + self.graph.set_below(boundary, Some(top)); + } } // Connect the parent-most entry to the reference's pick; a reference // parent-most (an empty range) re-points instead of gaining edges. The @@ -937,6 +1071,30 @@ impl Editor<'_, M> { self.insert_range_into(target, range, side, None, ParentReparentingOrder::Prepend) } + /// Move the positioned reference `subject` to sit `side` of `target` โ€” above or below + /// another reference, or onto a commit's stack โ€” without touching any commit. + /// + /// This is the single-reference form of disconnecting a one-entry range and reinserting + /// it: the reference leaves its group (members above close the gap, carried edges heal + /// onto its old pick) and re-anchors at the target. + pub fn move_reference( + &mut self, + subject: impl ToReferenceSelector, + target: impl ToSelector, + side: InsertSide, + ) -> Result<()> { + let subject = subject.to_reference_selector(self)?; + if !self.graph.is_positioned(subject.id) { + bail!("Can only move a reference that holds a position"); + } + let range = StepRange { + child: subject, + parent: subject, + }; + self.disconnect_range(range.clone())?; + self.insert_range(target, range, side) + } + /// Add a step to the graph, unconnected. Almost always you want [`Self::insert`] instead. pub fn add_step(&mut self, step: Step) -> Result { let new_idx = add_step_to_graph(&mut self.graph, step); @@ -950,6 +1108,16 @@ impl Editor<'_, M> { target: impl ToSelector, step: Step, side: InsertSide, + ) -> Result { + let out = self.insert_impl(target, step, side); + self.verified(out) + } + + fn insert_impl( + &mut self, + target: impl ToSelector, + step: Step, + side: InsertSide, ) -> Result { let target = target.to_selector(self)?; let inserting_reference = matches!(step, Step::Reference { .. }); @@ -1087,9 +1255,9 @@ impl Editor<'_, M> { /// Interpose a new pick between `target` (a reference positioned `on` its pick) and that /// pick: split the target's group at `boundary`, rest the split-off lower part on the new - /// pick, and redirect the entering edges when they should now enter through it โ€” always - /// for an `At` split; for an `Above` split only when nothing moved up (the target was the - /// group's top, so the whole group now rests on the new pick). + /// pick, and redirect the entering edges onto it โ€” they enter through the new pick now, + /// whether the target topped its group or members rode above (riders carry their edge + /// statements verbatim, so the arena edges must follow or the new pick is bypassed). fn interpose_pick_at_ref( &mut self, target: Selector, @@ -1110,10 +1278,8 @@ impl Editor<'_, M> { self.graph.push_parent(new_idx, pick); let split = split_group(&mut self.graph, target_ref, boundary, new_idx); settle_group_lower(&mut self.graph, &split.lower, (new_idx, 0)); - if matches!(boundary, SplitBoundary::At) || !split.moved_any { - // The group's edges now enter through the new pick, parent numbers untouched. - redirect_edges(&mut self.graph, &target_edges, pick, new_idx); - } + // The group's edges now enter through the new pick, parent numbers untouched. + redirect_edges(&mut self.graph, &target_edges, pick, new_idx); Ok(self.new_selector(new_idx)) } @@ -1138,6 +1304,16 @@ impl Editor<'_, M> { child: impl ToSelector, parent: impl ToSelector, parent_number: usize, + ) -> Result<()> { + let out = self.insert_edge_impl(child, parent, parent_number); + self.verified(out) + } + + fn insert_edge_impl( + &mut self, + child: impl ToSelector, + parent: impl ToSelector, + parent_number: usize, ) -> Result<()> { let child = child.to_selector(self)?; let parent = parent.to_selector(self)?; @@ -1197,9 +1373,41 @@ impl Editor<'_, M> { Ok(()) } - /// Removes all edges between a child and parent, returning the (pre-removal, ascending) + /// Sever all edges between a child and parent, returning the (pre-removal, ascending) /// parent numbers they occupied. Later parents shift down, statements following. - pub fn remove_edges( + /// + /// `detach` severs a link between two commits that both survive; [`Self::remove_commit`] + /// deletes a commit. + pub fn detach( + &mut self, + child: impl ToSelector, + parent: impl ToSelector, + ) -> Result> { + let out = self.detach_impl(child, parent); + self.verified(out) + } + + /// Re-point every `child`โ†’`from` edge onto `to`: the edges are removed and `to` + /// takes the LOWEST freed parent number, so the lane keeps its position. Several + /// freed edges collapse into one โ€” two parallel edges to one parent are almost + /// always unintentional, and keeping both would merge `to` twice. Returns the + /// freed parent numbers; empty when no edge connected `child` to `from` (then + /// nothing is inserted). + pub fn reparent_edges( + &mut self, + child: impl ToSelector, + from: impl ToSelector, + to: impl ToSelector, + ) -> Result> { + let child = child.to_selector(self)?; + let removed = self.detach(child, from)?; + if let Some(&parent_number) = removed.iter().min() { + self.insert_edge(child, to, parent_number)?; + } + Ok(removed) + } + + fn detach_impl( &mut self, child: impl ToSelector, parent: impl ToSelector, diff --git a/crates/but-rebase/src/graph_rebase/positions.rs b/crates/but-rebase/src/graph_rebase/positions.rs index 1ab04e9dd36..23dac2765ce 100644 --- a/crates/but-rebase/src/graph_rebase/positions.rs +++ b/crates/but-rebase/src/graph_rebase/positions.rs @@ -39,14 +39,21 @@ pub(crate) fn resolve_to_pick( EditorIndex::Pick(i) => PickIndex(i), entry @ EditorIndex::Ref(_) => graph.positioned_on(entry.as_ref()?)?, }; - // Tombstones descend their preserved first edge. - for _ in 0..10_000 { + // Tombstones descend their preserved first edge. Like ref_depth, the bound guards the + // acyclic invariant โ€” a broken invariant announces itself loudly in debug instead of + // returning a silent wrong answer. + let mut steps = 0usize; + loop { if graph.is_pick(node) { return Some(node); } node = *graph.parents(node).first()?; + steps += 1; + if steps >= 10_000 { + debug_assert!(false, "tombstone descent cycle resolving {node:?}"); + return None; + } } - None } /// A pick's incoming edges as sorted `(child, parent number)` pairs. The groups on the pick @@ -272,11 +279,8 @@ pub(crate) fn reposition_refs( /// /// Wired at editor creation AND at rebase entry, so every graph shape the suite produces โ€” /// including post-mutation shapes โ€” continuously validates the position model. -pub(crate) fn debug_assert_positions_total(graph: &GraphEditor) { - if !cfg!(debug_assertions) { - return; - } - debug_assert_below_wellformed(graph); +pub(crate) fn assert_positions_total(graph: &GraphEditor) -> anyhow::Result<()> { + assert_below_wellformed(graph)?; type OrderedPositionKey = (Option, Vec<(PickIndex, usize)>, usize); let mut seen: std::collections::HashMap = Default::default(); for entry in graph.references().map(|(entry, _, _)| entry) { @@ -291,19 +295,26 @@ pub(crate) fn debug_assert_positions_total(graph: &GraphEditor) { let pick = resolve_to_pick(graph, entry); let rank = ref_depth(graph, entry); if let Some(previous) = seen.insert((pick, entering.clone(), rank), entry) { - debug_assert!( - false, - "references {previous} and {entry} collide at position \ - (pick {pick:?}, entering {entering:?}, rank {rank})" + let name = |entry: RefIndex| match graph.reference(entry.into()) { + Some((refname, _)) => refname.to_string(), + None => format!("{:?}", graph.step_view(entry.into())), + }; + let groups = pick.and_then(|p| graph.groups_at_for_debug(p)); + anyhow::bail!( + "BUG: references {previous} ({}) and {entry} ({}) collide at position \ + (pick {pick:?}, entering {entering:?}, rank {rank})\ngroups: {groups:#?}", + name(previous), + name(entry) ); } } + Ok(()) } /// Every stored `below` of a LIVE reference names a positioned reference resolving to the SAME /// pick, and the below walk is acyclic. Tombstoned refs keep their stored position for /// retention reads but are spliced out of the physical stack, so only live refs are graded. -fn debug_assert_below_wellformed(graph: &GraphEditor) { +fn assert_below_wellformed(graph: &GraphEditor) -> anyhow::Result<()> { let name = |entry: RefIndex| match graph.reference(entry.into()) { Some((refname, _)) => refname.to_string(), None => format!("{:?}", graph.step_view(entry.into())), @@ -317,25 +328,21 @@ fn debug_assert_below_wellformed(graph: &GraphEditor) { let mut cursor = graph.below_of(entry); while let Some(b) = cursor { if !graph.is_positioned(b) { - debug_assert!( - false, - "ref {entry}: below {b} is not a positioned reference" + anyhow::bail!("BUG: ref {entry}: below {b} is not a positioned reference"); + } + if resolve_to_pick(graph, b) != pick { + anyhow::bail!( + "BUG: ref {entry} ({}): below {b} ({}) resolves to a different pick", + name(entry), + name(b) ); - return; } - debug_assert_eq!( - resolve_to_pick(graph, b), - pick, - "ref {entry} ({}): below {b} ({}) resolves to a different pick", - name(entry), - name(b) - ); depth += 1; - debug_assert!(depth <= 10_000, "ref {entry}: below walk cycle"); if depth > 10_000 { - return; + anyhow::bail!("BUG: ref {entry}: below walk cycle"); } cursor = graph.below_of(b); } } + Ok(()) } diff --git a/crates/but-rebase/src/graph_rebase/rebase.rs b/crates/but-rebase/src/graph_rebase/rebase.rs index 2db15726733..d988e72b161 100644 --- a/crates/but-rebase/src/graph_rebase/rebase.rs +++ b/crates/but-rebase/src/graph_rebase/rebase.rs @@ -26,7 +26,7 @@ impl<'meta, M: RefMetadata> Editor<'meta, M> { /// outstanding selector stay valid across the rebase. #[tracing::instrument(level = "debug", skip_all, err(Debug))] pub fn rebase(self) -> Result> { - crate::graph_rebase::positions::debug_assert_positions_total(&self.graph); + crate::graph_rebase::positions::assert_positions_total(&self.graph)?; let mut graph = self.graph; let mut history = self.history; diff --git a/crates/but-rebase/src/graph_rebase/ref_ops.rs b/crates/but-rebase/src/graph_rebase/ref_ops.rs index 1044b0d9dff..6f4eee6d505 100644 --- a/crates/but-rebase/src/graph_rebase/ref_ops.rs +++ b/crates/but-rebase/src/graph_rebase/ref_ops.rs @@ -484,9 +484,12 @@ pub(crate) struct GroupSplit { /// The members left behind, with their pre-split `(on, below)` โ€” settle them with /// [`settle_group_lower`] once the edge entering the lower part is known. pub lower: Vec<(RefIndex, PickIndex, Option)>, - /// Whether any member moved onto the upper entry. When none did, `at_ref` was the top - /// of its group, so the group's carried edges belong to the caller's new pick. - pub moved_any: bool, + /// The moved member that landed at the BOTTOM of the upper side (below cleared). The + /// caller re-hangs it on the upper pick's chain top AFTER its edge surgery settles โ€” + /// only then do carries resolve against final edges. + pub boundary: Option, + /// Every member that moved onto the upper entry (the boundary and its riders). + pub moved: Vec, } /// Split the group at `at_ref` around a pick interposed into it: members on the upper side of @@ -501,7 +504,8 @@ pub(crate) fn split_group( if !graph.is_positioned(at_ref) { return GroupSplit { lower: Vec::new(), - moved_any: false, + boundary: None, + moved: Vec::new(), }; } let members: Vec<_> = positions::group_members(graph, at_ref) @@ -531,6 +535,7 @@ pub(crate) fn split_group( } let mut lower = Vec::new(); let mut boundary_below = None; + let mut boundary = None; for (entry, on, below) in members { if moved.contains(&entry) { graph.rekey_position(entry, upper); @@ -538,6 +543,7 @@ pub(crate) fn split_group( // The boundary member lands at the bottom; its old below stays on the // lower side. boundary_below = below; + boundary = Some(entry); graph.set_below(entry, None); } } else { @@ -557,7 +563,8 @@ pub(crate) fn split_group( } GroupSplit { lower, - moved_any: !moved.is_empty(), + boundary, + moved, } } diff --git a/crates/but-rebase/src/graph_rebase/testing.rs b/crates/but-rebase/src/graph_rebase/testing.rs index 7b96ea910ba..08fb7f071b3 100644 --- a/crates/but-rebase/src/graph_rebase/testing.rs +++ b/crates/but-rebase/src/graph_rebase/testing.rs @@ -355,8 +355,11 @@ impl Editor<'_, M> { /// Render an entire [`Editor::graph_workspace`] projection for snapshot /// tests: the commits above the workspace, the workspace commit, then each /// stack in turn. Each section is rendered with [`Editor::subgraph_ascii`]. - pub fn graph_workspace_ascii(&self) -> Result { - let ws = self.graph_workspace()?; + pub fn graph_workspace_ascii( + &self, + partition: &but_graph::workspace::SegmentGraph, + ) -> Result { + let ws = self.graph_workspace(partition)?; let body = |rendered: String| { if rendered.is_empty() { "(empty)".to_string() diff --git a/crates/but-rebase/src/graph_rebase/workspace.rs b/crates/but-rebase/src/graph_rebase/workspace.rs index 0170e3b6aba..39246475940 100644 --- a/crates/but-rebase/src/graph_rebase/workspace.rs +++ b/crates/but-rebase/src/graph_rebase/workspace.rs @@ -153,9 +153,16 @@ impl NodeSet { } impl Editor<'_, M> { - /// Build a graph-based workspace projection framed from this editor. - pub fn graph_workspace(&self) -> Result { - let mut ws = self.graph_workspace_topology()?; + /// Build a graph-based workspace projection framed from this editor, with the + /// stack PARTITION supplied by the workspace's segment graph โ€” the one authority + /// for which refs and commits form which stack. The editor contributes what is + /// editor-native: the selector address space, the above-workspace region, and the + /// push/integration status decoration. + pub fn graph_workspace( + &self, + partition: &but_graph::workspace::SegmentGraph, + ) -> Result { + let mut ws = self.graph_workspace_topology(partition)?; // Every selector in the projection, so the status walks stay scoped to // the workspace rather than wandering down the full history. let entries: HashSet = ws @@ -172,9 +179,95 @@ impl Editor<'_, M> { Ok(ws) } - /// Build the topological skeleton of the projection (stacks, above-workspace, - /// workspace commit) with an empty [`GraphWorkspace::reference_status`]. - fn graph_workspace_topology(&self) -> Result { + /// Map the segment-graph `partition` into editor selectors: per stack, every + /// segment's naming reference and assigned commits. The head is the top segment's + /// reference (or its first commit). Refs the editor graph doesn't carry are + /// skipped โ€” status decoration would skip them too. + fn stacks_from_partition( + &self, + partition: &but_graph::workspace::SegmentGraph, + ) -> Vec { + // Refs that NAME a partition segment belong to exactly that stack โ€” they never + // ride along into a sibling that shares their anchor commit. + let naming: HashSet = partition + .stacks + .iter() + .flat_map(|s| s.segments.iter()) + .filter_map(|segment| segment.ref_name()) + .filter_map(|name| self.select_reference(name).ok()) + .map(|s| s.id) + .collect(); + partition + .stacks + .iter() + .map(|stack| { + let mut entries = HashSet::new(); + let mut heads = Vec::new(); + for (seg_idx, segment) in stack.segments.iter().enumerate() { + if let Some(name) = segment.ref_name() + && let Ok(sel) = self.select_reference(name) + { + if seg_idx == 0 { + heads.push(sel); + } + entries.insert(sel); + } + for &id in &segment.commits { + if let Some(sel) = self.try_select_commit(id) { + if heads.is_empty() && seg_idx == 0 && segment.tip() == Some(id) { + heads.push(sel); + } + entries.insert(sel); + } + } + } + if heads.is_empty() + && let Some(&first) = entries.iter().min_by_key(|s| s.id) + { + // Deterministic fallback: hash-set iteration order must not pick + // the render seed. + heads.push(first); + } + Subgraph { heads, entries } + }) + .filter(|subgraph| !subgraph.entries.is_empty()) + .map(|mut subgraph| { + // Riding references join the stack owning their position: the pick their + // entering edges resolve through, else their resolved pick โ€” the same + // membership the flooded-region attachment uses. + let commit_entries: HashSet = + subgraph.entries.iter().map(|s| s.id).collect(); + let additions: Vec = self + .graph + .positioned_refs() + .filter(|&entry| !naming.contains(&EditorIndex::from(entry))) + .filter(|&entry| { + positions::edges_through(&self.graph, entry) + .iter() + .any(|(child, _)| commit_entries.contains(&EditorIndex::from(*child))) + || positions::resolve_to_pick(&self.graph, EditorIndex::from(entry)) + .is_some_and(|pick| { + commit_entries.contains(&EditorIndex::from(pick)) + }) + }) + .collect(); + subgraph + .entries + .extend(additions.into_iter().map(|r| Selector { + id: EditorIndex::from(r), + })); + subgraph + }) + .collect() + } + + /// Build the topological skeleton of the projection (stacks from the supplied + /// partition, above-workspace, workspace commit) with an empty + /// [`GraphWorkspace::reference_status`]. + fn graph_workspace_topology( + &self, + partition: &but_graph::workspace::SegmentGraph, + ) -> Result { let Some(entrypoint_ix) = self.head_index() else { return Ok(GraphWorkspace::empty()); }; @@ -206,41 +299,55 @@ impl Editor<'_, M> { .unwrap_or_default(), }; - if on_workspace { - // The workspace commit, if present, lives somewhere in `HEAD ^target`. - let workspace_commit = region.entries.iter().copied().find_map(|ix| { - let id = self.graph.commit_id(ix)?; - let gix_commit = self.repo.find_commit(id).ok()?; - is_managed_workspace_by_message(gix_commit.message_raw().ok()?).then_some(ix) - }); - - if let Some(workspace_commit_ix) = workspace_commit { - let (above_workspace, stacks) = - divide_workspace_into_stacks(&self.graph, region, workspace_commit_ix); - - Ok(GraphWorkspace::topology( - above_workspace.into_subgraph(), - Some(self.new_selector(workspace_commit_ix)), - stacks.into_iter().map(|s| s.into_subgraph()).collect(), - )) - } else { - attach_flooded_refs(&self.graph, &mut region.entries, Some(entrypoint_ix)); - Ok(GraphWorkspace::topology( - region.into_subgraph(), - None, - vec![], - )) - } - } else { - // HEAD is on a plain branch, not the workspace: the whole `HEAD ^target` - // region is one stack. - attach_flooded_refs(&self.graph, &mut region.entries, Some(entrypoint_ix)); - Ok(GraphWorkspace::topology( - Subgraph::empty(), - None, - vec![region.into_subgraph()], - )) + // The workspace commit, if present, lives somewhere in `HEAD ^target`. + let workspace_commit = on_workspace + .then(|| { + region.entries.iter().copied().find_map(|ix| { + let id = self.graph.commit_id(ix)?; + let gix_commit = self.repo.find_commit(id).ok()?; + is_managed_workspace_by_message(gix_commit.message_raw().ok()?).then_some(ix) + }) + }) + .flatten(); + + // The stacks come from the PARTITION; the editor only contributes the + // above-workspace remainder: everything flooded that no stack claimed and + // that isn't the workspace commit itself, refs attached by position. + let stacks = self.stacks_from_partition(partition); + attach_flooded_refs(&self.graph, &mut region.entries, Some(entrypoint_ix)); + let stack_entries: HashSet = stacks + .iter() + .flat_map(|s| s.entries.iter()) + .map(|s| s.id) + .collect(); + // A metadata-less legacy shape can CLAIM the workspace commit into a stack (the + // walk starts on it); it then renders there, not as its own section. + let workspace_commit = workspace_commit.filter(|ix| !stack_entries.contains(ix)); + let claimed: HashSet = + stack_entries.into_iter().chain(workspace_commit).collect(); + region.entries.retain(|ix| !claimed.contains(ix)); + region.heads.retain(|ix| !claimed.contains(ix)); + if region.heads.is_empty() && !region.entries.is_empty() { + // The entrypoint was claimed by a stack: re-seed the remainder from its + // topmost entries (those no other remaining entry lists as a child). + let children: HashSet = region + .entries + .iter() + .flat_map(|&ix| self.graph.parents(ix)) + .map(EditorIndex::from) + .collect(); + region.heads = region + .entries + .iter() + .copied() + .filter(|ix| !children.contains(ix)) + .collect(); } + Ok(GraphWorkspace::topology( + region.into_subgraph(), + workspace_commit.map(|ix| self.new_selector(ix)), + stacks, + )) } /// The entrypoint (`HEAD`) reference entry, or `None` if HEAD isn't on a ref. @@ -332,6 +439,10 @@ impl Editor<'_, M> { /// empty-branch remote-tip fallback that `reference_integrated` has is /// intentionally not ported. Without a target there is nothing to integrate /// into, so both sets are empty. + /// + /// NOTE: this and the upstream-integration heuristics are a READ-ONLY projection + /// responsibility accreting on the mutation `Editor`. They could live on their own + /// type borrowing the editor, keeping the mutation surface focused. fn integration(&self, entries: &HashSet) -> Result { let Some(target_ref) = self.project_meta.target_ref.as_ref() else { return Ok(Integration::default()); @@ -618,161 +729,6 @@ fn combined_push_status( own_status } -/// Split the region beneath the workspace commit into mutually-exclusive stacks, -/// returning `(above_workspace, stacks)`. -/// -/// Membership is computed over PICKS: references are transparent for connectivity (they are -/// positions, not topology), so a shared ref entry cannot glue two distinct stacks -/// together. After the pick-flood, each reference joins the stack its position belongs to: -/// the stack of its entering child, else the stack of its resolved pick, and a group -/// hanging directly off the workspace commit keeps its own (possibly pick-less) stack โ€” -/// the empty-branch case. -fn divide_workspace_into_stacks( - graph: &GraphEditor, - head_not_target: NodeSet, - workspace_commit_ix: EditorIndex, -) -> (NodeSet, Vec) { - // Each parent of the workspace commit seeds a stack, flooded pick-to-pick: every outgoing - // edge resolves through reference/tombstone steps to the pick beneath. - let mut initial_stacks = graph - .parents(workspace_commit_ix) - .iter() - .copied() - .map(|head| { - let mut entries = std::collections::HashSet::new(); - let mut tips: Vec = Vec::new(); - if let Some(pick) = positions::resolve_to_pick(graph, head) - && head_not_target.entries.contains(&pick.into()) - { - entries.insert(pick.into()); - tips.push(pick); - } - while let Some(tip) = tips.pop() { - for parent in graph.parents(tip) { - let Some(pick) = positions::resolve_to_pick(graph, parent) else { - continue; - }; - if !head_not_target.entries.contains(&pick.into()) { - continue; - } - if entries.insert(pick.into()) { - tips.push(pick); - } - } - } - NodeSet { - heads: vec![head.into()], - entries, - } - }) - .collect::>(); - - // Merge stacks that share any pick (they aren't actually distinct). The pop-loop takes - // from the back, so reverse first: stacks come out in the workspace commit's parent number order, - // first parent first. - let mut deduplicated = vec![]; - initial_stacks.reverse(); - while let Some(mut out) = initial_stacks.pop() { - for bix in (0..initial_stacks.len()).rev() { - #[expect(clippy::indexing_slicing)] - if out - .entries - .iter() - .any(|o| initial_stacks[bix].entries.contains(o)) - { - let b = initial_stacks.swap_remove(bix); - out.entries.extend(b.entries); - out.heads.extend(b.heads); - } - } - deduplicated.push(out); - } - - // Each positioned reference joins the stack its position belongs to: the entering child's - // stack (with a group hanging straight off the workspace commit falling back to its - // pick's stack โ€” the workspace commit itself is in none), else the resolved pick's stack. - // References belonging to neither (e.g. the target's own ref above the excluded target - // commit) stay outside every stack. - for entry in graph.positioned_refs() { - let pick = positions::resolve_to_pick(graph, entry); - let entering = positions::edges_through(graph, entry); - let by_pick = |a: Option| { - a.and_then(|a| { - deduplicated - .iter() - .position(|s| s.entries.contains(&a.into())) - }) - }; - // Every entering edge must agree on the stack; a group entered from several stacks - // (or from the workspace commit itself) falls back to its pick's stack. A root group - // (no entering edges) has no stack โ€” no flood ever descended into it. - let pick_in_region = pick.is_some_and(|a| head_not_target.entries.contains(&a.into())); - let home = match entering.as_slice() { - // A root group: no flood ever descended into it โ€” no stack. - [] => None, - // A single entering edge follows its child's stack, even onto an excluded pick (a stack - // bottom resting on the target); a group hanging straight off the workspace - // commit falls back to its pick's stack. - [(child, _)] - if EditorIndex::from(*child) != workspace_commit_ix - && !graph.ambiguous_of(entry) => - { - deduplicated - .iter() - .position(|s| s.entries.contains(&EditorIndex::from(*child))) - .or_else(|| pick_in_region.then(|| by_pick(pick)).flatten()) - } - [_] => pick_in_region.then(|| by_pick(pick)).flatten(), - // A shared group: every edge must agree on the stack; otherwise it belongs to its - // pick's stack when that is in region, or nowhere. - many => { - let homes: Vec> = many - .iter() - .map(|(child, _)| { - if EditorIndex::from(*child) == workspace_commit_ix { - None - } else { - deduplicated - .iter() - .position(|s| s.entries.contains(&EditorIndex::from(*child))) - } - }) - .collect(); - match homes.as_slice() { - [Some(first), rest @ ..] if rest.iter().all(|h| *h == Some(*first)) => { - Some(*first) - } - _ => pick_in_region.then(|| by_pick(pick)).flatten(), - } - } - }; - if let Some(ix) = home { - #[expect(clippy::indexing_slicing)] - deduplicated[ix].entries.insert(entry.into()); - } - } - - let mut outside = head_not_target.entries.clone(); - attach_flooded_refs(graph, &mut outside, None); - for stack in &deduplicated { - outside = outside.difference(&stack.entries).copied().collect(); - } - outside.remove(&workspace_commit_ix); - - let above_workspace = NodeSet { - // The entrypoint is the tip of everything above the workspace commit. - heads: head_not_target - .heads - .iter() - .cloned() - .filter(|h| *h != workspace_commit_ix) - .collect(), - entries: outside, - }; - - (above_workspace, deduplicated) -} - /// Add the references that belong with the walked commits: groups entered by an in-region /// child, plus โ€” when the walk started at a reference โ€” that reference and its group /// members below it. Root groups nothing descends into (e.g. a remote ref stacked above a diff --git a/crates/but-rebase/tests/rebase/graph_rebase/disconnect.rs b/crates/but-rebase/tests/rebase/graph_rebase/disconnect.rs index 854f1e2601b..6cef18bf96c 100644 --- a/crates/but-rebase/tests/rebase/graph_rebase/disconnect.rs +++ b/crates/but-rebase/tests/rebase/graph_rebase/disconnect.rs @@ -4,7 +4,7 @@ use std::collections::HashSet; use anyhow::{Context, Result}; use but_graph::Workspace; -use but_rebase::graph_rebase::{Editor, Step, selector}; +use but_rebase::graph_rebase::{Editor, Step, mutate::Reconnect, selector}; use but_testsupport::{git_status, graph_dag, visualize_commit_graph_all}; use gix::prelude::ObjectIdExt; @@ -49,7 +49,7 @@ fn disconnect_and_remove_middle_commit_in_linear_history() -> Result<()> { target, selector::SelectorSet::All, selector::SelectorSet::All, - false, + Reconnect::Heal, )?; editor.replace(b_selector, Step::None)?; @@ -125,7 +125,7 @@ fn disconnect_and_remove_two_middle_commits_in_linear_history() -> Result<()> { range, selector::SelectorSet::All, selector::SelectorSet::All, - false, + Reconnect::Heal, )?; editor.replace(b_selector, Step::None)?; editor.replace(a_selector, Step::None)?; @@ -200,7 +200,7 @@ fn disconnect_and_remove_commit_in_merge_history_rewires_children() -> Result<() range, selector::SelectorSet::All, selector::SelectorSet::All, - false, + Reconnect::Heal, )?; editor.replace(a_selector, Step::None)?; @@ -291,7 +291,7 @@ fn disconnect_and_remove_merge_with_two_parents_and_two_children() -> Result<()> range, selector::SelectorSet::All, selector::SelectorSet::All, - false, + Reconnect::Heal, )?; editor.replace(merge_selector, Step::None)?; @@ -431,7 +431,7 @@ fn disconnect_and_remove_merge_with_two_parents_and_two_children_from_one_side() range, selector::SelectorSet::Some(selector::SomeSelectors::new(vec![child_one_selector])?), selector::SelectorSet::Some(selector::SomeSelectors::new(vec![parent_one_selector])?), - false, + Reconnect::Heal, )?; let outcome = editor.rebase()?; @@ -561,7 +561,7 @@ fn disconnect_remove_merge_with_two_parents_and_two_children_children_only() -> range, selector::SelectorSet::None, selector::SelectorSet::Some(selector::SomeSelectors::new(vec![parent_one_selector])?), - false, + Reconnect::Heal, )?; let outcome = editor.rebase()?; @@ -716,12 +716,12 @@ fn disconnect_fails_when_parents_to_disconnect_is_none() -> Result<()> { range, selector::SelectorSet::Some(selector::SomeSelectors::new(vec![child_one_selector])?), selector::SelectorSet::None, - false, + Reconnect::Heal, ) .expect_err("expected disconnect to fail for parents=SelectorSet::None"); assert!( err.to_string() - .contains("Invalid parents to disconnect: SelectorSet::None is not allowed"), + .contains("Invalid parents to disconnect: SelectorSet::None requires Reconnect::Skip"), "unexpected error: {err:#}" ); @@ -792,7 +792,7 @@ fn disconnect_fails_fast_if_parent_to_disconnect_is_not_direct_parent() -> Resul range, selector::SelectorSet::Some(selector::SomeSelectors::new(vec![child_one_selector])?), selector::SelectorSet::Some(selector::SomeSelectors::new(vec![child_one_selector])?), - false, + Reconnect::Heal, ) .expect_err("expected disconnect to fail for non-parent selector"); assert!( @@ -868,7 +868,7 @@ fn disconnect_fails_fast_if_child_to_disconnect_is_not_direct_child() -> Result< range, selector::SelectorSet::Some(selector::SomeSelectors::new(vec![parent_one_selector])?), selector::SelectorSet::Some(selector::SomeSelectors::new(vec![parent_one_selector])?), - false, + Reconnect::Heal, ) .expect_err("expected disconnect to fail for non-child selector"); assert!( diff --git a/crates/but-rebase/tests/rebase/graph_rebase/edge.rs b/crates/but-rebase/tests/rebase/graph_rebase/edge.rs index ea5b1a3b090..d64171f674a 100644 --- a/crates/but-rebase/tests/rebase/graph_rebase/edge.rs +++ b/crates/but-rebase/tests/rebase/graph_rebase/edge.rs @@ -1,4 +1,4 @@ -//! These tests exercise the add_step, insert_edge and remove_edge operations. +//! These tests exercise the add_step, insert_edge and detach operations. use anyhow::Result; use but_core::Commit; @@ -182,7 +182,7 @@ fn remove_edge_returns_no_orders_when_no_edges_found() -> Result<()> { let c_selector = editor.select_commit(c)?; let a_selector = editor.select_commit(a)?; - editor.remove_edges(c_selector, a_selector)?; + editor.detach(c_selector, a_selector)?; Ok(()) } @@ -205,7 +205,7 @@ fn removing_an_existing_edge_returns_its_order_and_allows_readding_it() -> Resul let b_selector = editor.select_commit(b)?; let a_selector = editor.select_commit(a)?; - assert_eq!(editor.remove_edges(b_selector, a_selector)?, vec![0]); + assert_eq!(editor.detach(b_selector, a_selector)?, vec![0]); editor.insert_edge(b_selector, a_selector, 0)?; snapbox::assert_data_eq!( diff --git a/crates/but-rebase/tests/rebase/graph_rebase/editor_creation.rs b/crates/but-rebase/tests/rebase/graph_rebase/editor_creation.rs index f6db318562f..d29cdaaf934 100644 --- a/crates/but-rebase/tests/rebase/graph_rebase/editor_creation.rs +++ b/crates/but-rebase/tests/rebase/graph_rebase/editor_creation.rs @@ -560,7 +560,7 @@ fn workspace_with_target_at_stack_base_no_local_main() -> Result<()> { fn ops_on_immutable_refs_fail() -> Result<()> { use but_rebase::graph_rebase::{ Step, - mutate::InsertSide, + mutate::{InsertSide, Reconnect}, selector::{SelectorSet, StepRange}, }; @@ -603,7 +603,7 @@ fn ops_on_immutable_refs_fail() -> Result<()> { }, SelectorSet::All, SelectorSet::All, - false, + Reconnect::Heal, ) .unwrap_err() .to_string(), diff --git a/crates/but-rebase/tests/rebase/graph_rebase/graph_workspace.rs b/crates/but-rebase/tests/rebase/graph_rebase/graph_workspace.rs index 912f25f17c2..f07f72832c7 100644 --- a/crates/but-rebase/tests/rebase/graph_rebase/graph_workspace.rs +++ b/crates/but-rebase/tests/rebase/graph_rebase/graph_workspace.rs @@ -1,12 +1,13 @@ //! Snapshot tests for the [`GraphWorkspace`] projection, covering the //! permutations of [`Editor::graph_workspace`]: //! -//! - on the workspace branch with a workspace commit, split into one or more -//! stacks (with and without a target bounding them); -//! - stacks that share ancestry and therefore collapse into a single stack; +//! - on the workspace branch with a workspace commit, split into the DECLARED +//! stacks (with and without a target bounding them) โ€” the partition comes from +//! the workspace's segment graph, so shared ancestry no longer collapses stacks; //! - pegged onto a non-workspace branch (one stack of everything), with and //! without a target; -//! - on the workspace branch but with no discoverable workspace commit. +//! - on the workspace branch but with no discoverable workspace commit, and +//! metadata-less legacy shapes where the walk runs through the workspace ref. //! //! [`GraphWorkspace`]: but_rebase::graph_rebase::GraphWorkspace @@ -22,23 +23,46 @@ use crate::utils::{fixture_writable, standard_options}; /// Build an editor for `fixture` (optionally bounded by `target`, a revspec /// resolved against the repo) and render its [`Editor::graph_workspace`] /// projection. All borrows stay local so callers just snapshot the string. -fn render(fixture: &str, target: Option<&str>) -> Result { +fn render(fixture: &str, target: Option<&str>, stacks: &[&[&str]]) -> Result { let (repo, _tmp, mut meta) = fixture_writable(fixture)?; - let mut ws = Workspace::from_head(&repo, &*meta, ProjectMeta::default(), standard_options())? - .validated()?; - - // The projection bounds stacks at the target commit, so wire it onto the - // workspace graph that the editor reads from. - *ws.project_meta_mut() = ProjectMeta { + // The partition comes from DECLARED stacks; a fixture without declarations + // exercises the metadata-less legacy shapes. + if !stacks.is_empty() { + use but_core::RefMetadata as _; + let ws_ref: gix::refs::FullName = but_core::WORKSPACE_REF_NAME.try_into()?; + let mut ws_md = meta.workspace(ws_ref.as_ref())?; + for (idx, branches) in stacks.iter().enumerate() { + ws_md.stacks.push(but_core::ref_metadata::WorkspaceStack { + id: but_core::ref_metadata::StackId::from_number_for_testing(idx as u128 + 1), + branches: branches + .iter() + .map(|name| { + Ok(but_core::ref_metadata::WorkspaceStackBranch { + ref_name: format!("refs/heads/{name}").try_into()?, + archived: false, + parents: None, + }) + }) + .collect::>()?, + workspacecommit_relation: but_core::ref_metadata::WorkspaceCommitRelation::Merged, + }); + } + meta.set_workspace(&ws_md)?; + } + + // The projection bounds stacks at the target commit; the partition must be built + // with the same target, so it goes into the build-time project meta. + let project_meta = ProjectMeta { target_commit_id: target .map(|t| repo.rev_parse_single(t).map(|id| id.detach())) .transpose()?, ..Default::default() }; + let ws = Workspace::from_head(&repo, &*meta, project_meta, standard_options())?.validated()?; let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut *meta, &repo)?; - editor.graph_workspace_ascii() + editor.graph_workspace_ascii(&ws.segment_graph) } /// A linear workspace (baseโ†’aโ†’bโ†’c under the workspace commit) with no target, @@ -59,7 +83,7 @@ fn single_stack_no_target() -> Result<()> { ); snapbox::assert_data_eq!( - render("workspace-signed", None)?, + render("workspace-signed", None, &[&["c", "b", "a", "base"]])?, snapbox::str![[r#" # Above workspace โ—Ž refs/heads/gitbutler/workspace @@ -87,7 +111,11 @@ fn single_stack_no_target() -> Result<()> { #[test] fn single_stack_with_target() -> Result<()> { snapbox::assert_data_eq!( - render("workspace-signed", Some("base"))?, + render( + "workspace-signed", + Some("base"), + &[&["c", "b", "a", "base"]] + )?, snapbox::str![[r#" # Above workspace โ—Ž refs/heads/gitbutler/workspace @@ -113,7 +141,7 @@ fn single_stack_with_target() -> Result<()> { /// is an ancestor of stack-1's tip). With no target they share ancestry, so the /// de-duplication merges them into a single stack. #[test] -fn overlapping_stacks_merge_into_one() -> Result<()> { +fn declared_stack_owns_overlapping_commits() -> Result<()> { let (repo, _tmp, _meta) = fixture_writable("workspace-with-empty-stack")?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -134,9 +162,16 @@ fn overlapping_stacks_merge_into_one() -> Result<()> { ); snapbox::assert_data_eq!( - render("workspace-with-empty-stack", None)?, + render( + "workspace-with-empty-stack", + None, + &[&["stack-1"], &["stack-2"]] + )?, snapbox::str![[r#" # Above workspace +โ— f555940 Commit A +โ— d664be0 Commit B +โ— fafd9d0 init โ—Ž refs/heads/gitbutler/workspace # Workspace commit @@ -146,10 +181,9 @@ fn overlapping_stacks_merge_into_one() -> Result<()> { โ—Ž refs/heads/stack-1 โ— 2169646 Commit D โ— 46ef828 Commit C + +# Stack 1 โ—Ž refs/heads/stack-2 -โ— f555940 Commit A -โ— d664be0 Commit B -โ— fafd9d0 init "#]] ); Ok(()) @@ -158,7 +192,7 @@ fn overlapping_stacks_merge_into_one() -> Result<()> { /// Three stacks that all point at the same base commit. They share that node, /// so they collapse into a single stack. #[test] -fn three_stacks_same_base_collapse() -> Result<()> { +fn three_empty_stacks_same_base_stay_declared() -> Result<()> { let (repo, _tmp, _meta) = fixture_writable("workspace-with-three-empty-stacks")?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, @@ -172,7 +206,11 @@ fn three_stacks_same_base_collapse() -> Result<()> { ); snapbox::assert_data_eq!( - render("workspace-with-three-empty-stacks", None)?, + render( + "workspace-with-three-empty-stacks", + None, + &[&["stack-1"], &["stack-2"], &["stack-3"]] + )?, snapbox::str![[r#" # Above workspace โ—Ž refs/heads/gitbutler/workspace @@ -182,7 +220,13 @@ fn three_stacks_same_base_collapse() -> Result<()> { # Stack 0 โ—Ž refs/heads/stack-1 +โ— fafd9d0 init + +# Stack 1 โ—Ž refs/heads/stack-2 +โ— fafd9d0 init + +# Stack 2 โ—Ž refs/heads/stack-3 โ— fafd9d0 init "#]] @@ -216,7 +260,11 @@ fn divergent_stacks_sharing_excluded_base_stay_separate_with_target() -> Result< ); snapbox::assert_data_eq!( - render("workspace-two-stacks", Some("main"))?, + render( + "workspace-two-stacks", + Some("main"), + &[&["stack-a"], &["stack-b"]] + )?, snapbox::str![[r#" # Above workspace โ—Ž refs/heads/gitbutler/workspace @@ -228,11 +276,13 @@ fn divergent_stacks_sharing_excluded_base_stay_separate_with_target() -> Result< โ—Ž refs/heads/stack-a โ— 49c06ff A2 โ— ff76d2f A1 +โ—Ž refs/heads/main # Stack 1 โ—Ž refs/heads/stack-b โ— afc3f8f B2 โ— b3ee99c B1 +โ—Ž refs/heads/main "#]] ); Ok(()) @@ -241,11 +291,12 @@ fn divergent_stacks_sharing_excluded_base_stay_separate_with_target() -> Result< /// The same divergent fixture with no target: both stacks reach `base`, share /// it (and the refs above it), and therefore merge into a single stack. #[test] -fn divergent_stacks_sharing_base_merge() -> Result<()> { +fn divergent_stacks_sharing_base_stay_separate() -> Result<()> { snapbox::assert_data_eq!( - render("workspace-two-stacks", None)?, + render("workspace-two-stacks", None, &[&["stack-a"], &["stack-b"]])?, snapbox::str![[r#" # Above workspace +โ— 965998b base โ—Ž refs/heads/gitbutler/workspace # Workspace commit @@ -255,12 +306,13 @@ fn divergent_stacks_sharing_base_merge() -> Result<()> { โ—Ž refs/heads/stack-a โ— 49c06ff A2 โ— ff76d2f A1 -โ”‚ โ—Ž refs/heads/stack-b -โ”‚ โ— afc3f8f B2 -โ”‚ โ— b3ee99c B1 -โ”œโ”€โ•ฏ โ—Ž refs/heads/main -โ— 965998b base + +# Stack 1 +โ—Ž refs/heads/stack-b +โ— afc3f8f B2 +โ— b3ee99c B1 +โ—Ž refs/heads/main "#]] ); Ok(()) @@ -283,7 +335,7 @@ fn pegged_no_target() -> Result<()> { ); snapbox::assert_data_eq!( - render("four-commits", None)?, + render("four-commits", None, &[])?, snapbox::str![[r#" # Above workspace (empty) @@ -307,7 +359,7 @@ fn pegged_no_target() -> Result<()> { #[test] fn pegged_with_target() -> Result<()> { snapbox::assert_data_eq!( - render("four-commits", Some("main~3"))?, + render("four-commits", Some("main~3"), &[])?, snapbox::str![[r#" # Above workspace (empty) @@ -320,6 +372,7 @@ fn pegged_with_target() -> Result<()> { โ— 120e3a9 c โ— a96434e b โ— d591dfe a +โ— 35b8235 base "#]] ); Ok(()) @@ -347,25 +400,29 @@ fn disjoint_stacks_stay_separate() -> Result<()> { ); snapbox::assert_data_eq!( - render("workspace-disjoint-stacks", None)?, + render( + "workspace-disjoint-stacks", + None, + &[&["stack-a"], &["stack-b"]] + )?, snapbox::str![[r#" # Above workspace +โ— 49c06ff A2 +โ— ff76d2f A1 +โ—Ž refs/heads/main +โ— 965998b base โ—Ž refs/heads/gitbutler/workspace # Workspace commit โ— f97c026 GitButler Workspace Commit # Stack 0 -โ—Ž refs/heads/stack-a -โ— 49c06ff A2 -โ— ff76d2f A1 -โ—Ž refs/heads/main -โ— 965998b base - -# Stack 1 โ—Ž refs/heads/stack-b โ— cb7021b B2 โ— ce3278a B1 + +# Stack 1 +โ—Ž refs/heads/stack-a "#]] ); Ok(()) @@ -379,7 +436,11 @@ fn disjoint_stacks_stay_separate() -> Result<()> { #[test] fn disjoint_stacks_stay_separate_with_target() -> Result<()> { snapbox::assert_data_eq!( - render("workspace-disjoint-stacks", Some("main"))?, + render( + "workspace-disjoint-stacks", + Some("main"), + &[&["stack-a"], &["stack-b"]] + )?, snapbox::str![[r#" # Above workspace โ—Ž refs/heads/gitbutler/workspace @@ -391,6 +452,7 @@ fn disjoint_stacks_stay_separate_with_target() -> Result<()> { โ—Ž refs/heads/stack-a โ— 49c06ff A2 โ— ff76d2f A1 +โ—Ž refs/heads/main # Stack 1 โ—Ž refs/heads/stack-b @@ -418,17 +480,22 @@ fn workspace_branch_without_managed_commit() -> Result<()> { ); snapbox::assert_data_eq!( - render("workspace-without-managed-commit", None)?, + render("workspace-without-managed-commit", None, &[])?, snapbox::str![[r#" # Above workspace +(empty) + +# Workspace commit +(empty) + +# Stack 0 โ—Ž refs/heads/gitbutler/workspace โ— 1b78c63 just a normal commit โ—Ž refs/heads/main +โ”‚ โ—Ž refs/remotes/origin/main (immutable) +โ”œโ”€โ•ฏ โ— 4d41a5c one โ— 965998b base - -# Workspace commit -(empty) "#]] ); Ok(()) diff --git a/crates/but-rebase/tests/rebase/graph_rebase/order_commit_selectors_by_parentage.rs b/crates/but-rebase/tests/rebase/graph_rebase/order_commit_selectors_by_parentage.rs index 06ba6c2c3b6..208157f79af 100644 --- a/crates/but-rebase/tests/rebase/graph_rebase/order_commit_selectors_by_parentage.rs +++ b/crates/but-rebase/tests/rebase/graph_rebase/order_commit_selectors_by_parentage.rs @@ -1,6 +1,8 @@ use anyhow::Result; use but_graph::Workspace; -use but_rebase::graph_rebase::{Editor, LookupStep, Step, selector, testing::Testing as _}; +use but_rebase::graph_rebase::{ + Editor, LookupStep, Step, mutate::Reconnect, selector, testing::Testing as _, +}; use but_testsupport::visualize_commit_graph_all; use snapbox::prelude::*; @@ -466,7 +468,7 @@ fn orders_commit_disconnected_from_checkout_roots_if_still_in_editor_graph() -> }, selector::SelectorSet::All, selector::SelectorSet::All, - true, + Reconnect::Skip, )?; let ordered = editor.order_commit_selectors_by_parentage([b])?; @@ -544,7 +546,7 @@ fn orders_all_commits_in_y_shaped_two_branch_fixture() -> Result<()> { }, selector::SelectorSet::All, selector::SelectorSet::None, - true, + Reconnect::Skip, )?; // The right reference should still exist, but its tip commit should no longer have commit children. diff --git a/crates/but-rebase/tests/rebase/graph_rebase/sha256.rs b/crates/but-rebase/tests/rebase/graph_rebase/sha256.rs index ce890c39c55..dae4ae51be6 100644 --- a/crates/but-rebase/tests/rebase/graph_rebase/sha256.rs +++ b/crates/but-rebase/tests/rebase/graph_rebase/sha256.rs @@ -241,7 +241,7 @@ Sha256 let b_ref_selector = editor.select_reference(b_refname.as_ref())?; let (b_selector, _) = editor.find_reference_target(b_ref_selector)?; - let removed_orders = editor.remove_edges(inner_merge_selector, b_ref_selector)?; + let removed_orders = editor.detach(inner_merge_selector, b_ref_selector)?; snapbox::assert_data_eq!( removed_orders.to_debug(), snapbox::str![[r#" diff --git a/crates/but-testsupport/src/graph.rs b/crates/but-testsupport/src/graph.rs index 8cd75c16024..38fd324286e 100644 --- a/crates/but-testsupport/src/graph.rs +++ b/crates/but-testsupport/src/graph.rs @@ -26,7 +26,10 @@ fn graph_workspace_inner( Default::default() }; let mut root = Tree::new(workspace.debug_string()); - for stack in &workspace.stacks { + let display_stacks = workspace + .display_stacks() + .expect("BUG: display must derive for rendering"); + for stack in &display_stacks { root.push(tree_for_stack( workspace, stack, diff --git a/crates/but-testsupport/src/in_memory_meta.rs b/crates/but-testsupport/src/in_memory_meta.rs index db145573760..4d727ff3079 100644 --- a/crates/but-testsupport/src/in_memory_meta.rs +++ b/crates/but-testsupport/src/in_memory_meta.rs @@ -275,5 +275,6 @@ fn stack_segment_from_partial_name(name: impl TryInto) -> Workspace .unwrap() }, archived: false, + parents: None, } } diff --git a/crates/but-transaction/src/lib.rs b/crates/but-transaction/src/lib.rs index 5ba25f37d38..371b63c114b 100644 --- a/crates/but-transaction/src/lib.rs +++ b/crates/but-transaction/src/lib.rs @@ -441,7 +441,7 @@ where if matches!( position, but_workspace::branch::create_reference::Position::Below - ) && segment.commits.is_empty() + ) && segment.tip().is_none() { ( Some( @@ -456,8 +456,10 @@ where let oldest_commit_id = segment .commits .last() - .map(|commit| commit.id) - .or_else(|| workspace.branch_resting_commit_id(ref_name.as_ref())) + .copied() + .or_else(|| { + workspace.branch_resting_commit_id_in_display(ref_name.as_ref()) + }) .ok_or_else(|| { anyhow::anyhow!( "Cannot position reference below unborn segment '{}'", @@ -476,7 +478,7 @@ where anchor => (anchor, None), }; let mut meta = RecordingMetadata { - workspace_name: workspace.ref_name().map(ToOwned::to_owned), + workspace_name: workspace.ref_name_owned(), workspace: workspace.metadata.clone(), updates: Vec::new(), }; @@ -1176,7 +1178,7 @@ fn workspace_state_from_rebase( } let outcome = but_workspace::branch::apply( branch.name.as_ref(), - workspace.clone(), + &workspace, repo, materialized.meta, but_workspace::branch::apply::Options { diff --git a/crates/but-workspace/src/branch/apply.rs b/crates/but-workspace/src/branch/apply.rs index 0be5c88a9b1..33f120cb8b7 100644 --- a/crates/but-workspace/src/branch/apply.rs +++ b/crates/but-workspace/src/branch/apply.rs @@ -4,6 +4,7 @@ use but_core::{ }; use crate::branch::{OnWorkspaceMergeConflict, try_find_validated_ref}; +use std::ops::ControlFlow; /// A stack that conflicted while applying a branch. #[derive(Clone)] @@ -93,6 +94,11 @@ impl Outcome { pub fn workspace_changed(&self) -> bool { !matches!(self.status, OutcomeStatus::AlreadyApplied) } + + /// The resulting workspace, cloned โ€” a convenience for render sites. + pub fn display_workspace(&self) -> anyhow::Result { + Ok(self.workspace.clone()) + } } impl std::fmt::Debug for Outcome { @@ -183,10 +189,7 @@ use but_graph::{ use gix::{ prelude::ObjectIdExt, reference::Category, - refs::{ - FullNameRef, Target, - transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}, - }, + refs::{FullNameRef, Target, transaction::PreviousValue}, }; use tracing::instrument; @@ -194,7 +197,6 @@ use crate::{ WorkspaceCommit, branch::{anon_stacks, ensure_no_missing_stacks}, commit::merge::Seed, - ref_info::WorkspaceExt, }; /// Apply `branch` to the given `workspace`, and possibly create the workspace reference in `repo` @@ -221,7 +223,7 @@ use crate::{ #[instrument(skip(workspace, repo, meta), err(Debug))] pub fn apply( branch: &FullNameRef, - workspace: but_graph::Workspace, + workspace: &but_graph::Workspace, repo: &gix::Repository, meta: &mut impl RefMetadata, Options { @@ -232,112 +234,19 @@ pub fn apply( new_stack_id, }: Options, ) -> anyhow::Result { - let ws = workspace; let new_stack_id = new_stack_id.unwrap_or(generate_new_stack_id); let branch_orig = branch; - let (mut branch_ref, mut incoming_branch_is_remote_tracking_without_local_tracking) = - (try_find_validated_ref(repo, branch, "apply")?, false); - if ws.is_target_or_its_local_tracking(branch) { - bail!("Cannot add the target '{branch}' branch to its own workspace"); - } - let mut branch = branch.to_owned(); - if branch - .category() - .is_some_and(|c| c == Category::RemoteBranch) - { - // TODO(gix): we really want to have a function to return the local tracking branch - // fix this in other places, too. - let Some((upstream_branch_name, _remote_name)) = - repo.upstream_branch_and_remote_for_tracking_branch(branch.as_ref())? - else { - // TODO: actually create a local trakcing branch with proper configuration. - bail!("Couldn't find remote refspecs that would match {branch}"); - }; - // Pretend the upstream branch is also the local tracking name. - incoming_branch_is_remote_tracking_without_local_tracking = true; - branch = upstream_branch_name; - branch_ref = try_find_validated_ref(repo, branch.as_ref(), "apply")?; - } - let branch_has_applied_metadata = - branch_has_applied_workspace_metadata(branch.as_ref(), &ws, meta)?; - let branch_already_applied = - ws.is_reachable_from_entrypoint(branch.as_ref()) && branch_has_applied_metadata; - if branch_already_applied { - let workspace_ref_created = false; - // When exiting early, don't try to adjust the ws commit. - return Ok(Outcome { - workspace: ws, - status: OutcomeStatus::AlreadyApplied, - workspace_ref_created, - workspace_merge: None, - conflicting_stacks: Vec::new(), - applied_branches: Vec::new(), - }); - } else if !branch_has_applied_metadata && ws.refname_is_segment(branch.as_ref()) { - // This means our workspace encloses the desired branch, but it's not checked out yet. - let commit_to_checkout = ws - .tip_commit_id() - .context("Workspace must point to a commit to check out")?; - let ws_ref_name = ws.ref_name().map(|rn| rn.to_owned()); - but_core::worktree::safe_checkout_from_head( - commit_to_checkout, - repo, - but_core::worktree::checkout::Options { - skip_head_update: true, - ..Default::default() - }, - )?; - let applied_branches = vec![branch.to_owned()]; - if !branch_has_applied_metadata { - let ws_ref_name = ws_ref_name - .as_ref() - .context("Workspace metadata must be available to repair stale applied state")?; - let mut ws_md = meta.workspace(ws_ref_name.as_ref())?; - add_branch_as_stack_forcefully(&mut ws_md, branch.as_ref(), order, new_stack_id); - persist_metadata_and_gitconfig(meta, &applied_branches, &ws_md, None)?; - } - let ws = ws.redo( - repo, - meta, - Overlay::default().with_entrypoint(commit_to_checkout, ws_ref_name.clone()), - )?; - set_head_to_reference( - repo, - commit_to_checkout, - ws_ref_name.as_ref().map(|rn| rn.as_ref()), - )?; - - // When exiting early, don't try to adjust the ws commit. - return Ok(Outcome { - workspace: ws, - status: OutcomeStatus::Applied, - workspace_ref_created: false, - workspace_merge: None, - conflicting_stacks: Vec::new(), - applied_branches, - }); + let ( + ws, + ResolvedBranch { + branch, + branch_ref, + incoming_branch_is_remote_tracking_without_local_tracking, + }, + ) = match resolve_and_validate(branch, workspace.clone(), repo, meta, order, new_stack_id)? { + ControlFlow::Break(outcome) => return Ok(outcome), + ControlFlow::Continue(resolved) => resolved, }; - - if let Some(ws_ref_name) = ws.ref_name() - && repo.try_find_reference(ws_ref_name)?.is_none() - { - // The workspace is the probably ad-hoc, and doesn't exist, *assume* unborn. - bail!( - "Cannot create reference on unborn branch '{}'", - ws_ref_name.shorten() - ); - } - - if ws.has_workspace_commit_in_ancestry(repo) { - bail!("Refusing to work on workspace whose workspace commit isn't at the top"); - } - - if meta.workspace_opt(branch.as_ref())?.is_some() { - bail!( - "Refusing to apply a reference that already is a workspace: '{}'", - branch.shorten() - ); - } // In general, we only have to deal with one branch to apply. But when we are on an adhoc workspace, // we need to assure both branches go into the existing or the new workspace: // - the current one and the one to apply, if these are different. @@ -414,45 +323,15 @@ pub fn apply( .find_branch(head.as_ref(), StackKind::Applied) .is_some() }); - { - let ws_mut: &mut Workspace = &mut ws_md; - // Demote previously-applied stacks before re-applying, in two situations: - // - stale AdHoc metadata left after switching out of the workspace: the stack no longer - // appears in the projection, and - // - re-rooting around a branch we have checked out that's already in the workspace: keep - // only that branch and the branch being applied. - // Ref names the projection (`ws`) contains, in AdHoc mode only; `None` in a managed - // workspace, where the metadata is authoritative so projection absence doesn't demote. - let projected_refs = matches!(ws.kind, WorkspaceKind::AdHoc).then(|| { - ws.stacks - .iter() - .flat_map(|s| s.segments.iter()) - .filter_map(|seg| seg.ref_name().map(|rn| rn.as_bstr())) - .collect::>() - }); - for stack in &mut ws_mut.stacks { - let dropped_from_projection = projected_refs.as_ref().is_some_and(|projected| { - !stack - .branches - .iter() - .any(|b| projected.contains(b.ref_name.as_ref().as_bstr())) - }); - let stack_is_kept = stack.branches.iter().any(|b| { - branches_to_apply - .iter() - .any(|rn| rn.as_ref() == b.ref_name.as_ref()) - || head_ref_name - .as_ref() - .is_some_and(|head| head.as_ref() == b.ref_name.as_ref()) - }); - if dropped_from_projection || (head_branch_in_workspace && !stack_is_kept) { - stack.workspacecommit_relation = Outside; - } - } - for rn in &branches_to_apply { - add_branch_as_stack_forcefully(ws_mut, rn.as_ref(), order, new_stack_id); - } - } + restage_metadata_stacks( + &mut ws_md, + &ws, + &branches_to_apply, + head_ref_name.as_ref(), + head_branch_in_workspace, + order, + new_stack_id, + ); let ws_md_retry_base = ws_md.clone(); let (local_tracking_config_and_ref_info, commit_to_create_branch_at) = @@ -568,44 +447,24 @@ pub fn apply( // These are, however, part of the graph by now, and we want to try to create a workspace // merge. let mut in_memory_repo = repo.clone().for_tree_diffing()?.with_object_memory(); - let mut merge_result = WorkspaceCommit::from_new_merge_with_metadata( - filter_superseded_metadata_stacks( - ws_md.stacks.iter(), - &existing_stacks_superseded_by_branch, - ), - filter_superseded_anon_stacks( - anon_stacks(&ws.stacks), - &existing_stacks_superseded_by_branch, - ), - &ws, + let merged = match merge_workspace_and_redo( + ws, + &mut ws_md, + &overlay, &in_memory_repo, - Some(branch.as_ref()), - )?; - ensure_no_missing_stacks(&merge_result)?; - drop(existing_stacks_superseded_by_branch); - - if merge_result.has_conflicts() && on_workspace_conflict.should_abort() { - let conflicting_stacks = - correlate_conflicting_stacks(&ws_md, &merge_result.conflicting_stacks); - return Ok(Outcome { - workspace: ws, - status: OutcomeStatus::ConflictAborted, - workspace_ref_created: false, - workspace_merge: Some(merge_result), - conflicting_stacks, - applied_branches: Vec::new(), - }); - } - - let mut new_head_id = merge_result.workspace_commit_id; - let mut conflicting_stacks = - correlate_conflicting_stacks(&ws_md, &merge_result.conflicting_stacks); - remove_conflicting_stacks_from_workspace(&mut ws_md, &conflicting_stacks); - let ws_md_override = Some((workspace_ref_name_to_update.clone(), (*ws_md).clone())); - let overlay = overlay - .with_entrypoint(new_head_id, Some(workspace_ref_name_to_update.clone())) - .with_workspace_metadata_override(ws_md_override); - let mut ws = ws.redo(&in_memory_repo, meta, overlay.clone())?; + meta, + branch.as_ref(), + &workspace_ref_name_to_update, + on_workspace_conflict, + &existing_stacks_superseded_by_branch, + )? { + MergeAttempt::Aborted(outcome) => return Ok(outcome), + MergeAttempt::Merged(merged) => merged, + }; + let mut merge_result = merged.merge_result; + let mut new_head_id = merged.new_head_id; + let mut conflicting_stacks = merged.conflicting_stacks; + let mut ws = merged.ws; let collect_unapplied_branches = |ws: &but_graph::Workspace| { branches_to_apply .iter() @@ -617,33 +476,21 @@ pub fn apply( // Now that the merge is done, try to redo the operation one last time with dependent branches instead. // Only do that for the still unapplied branches, which should always find some sort of anchor. let ws_mut: &mut Workspace = &mut ws_md; + // Reset to the post-restage metadata, where every branch-to-apply is already a stack. The + // came-through branches stay stacks as-is (the loop below only re-homes the still-unapplied + // ones as dependent branches), so re-adding them here would be a no-op. *ws_mut = ws_md_retry_base; - for branch_to_add in branches_to_apply - .iter() - .filter(|rn| !unapplied_branches.contains(rn)) - { - add_branch_as_stack_forcefully(ws_mut, branch_to_add.as_ref(), order, new_stack_id); - } for rn in &unapplied_branches { // Here we have to check if the new ref would be able to become its own stack, // or if it has to be a dependent branch. Stacks only work if the ref rests on a base // outside the workspace, so if we find it in the workspace (in an ambiguous spot) it must be // a dependent branch if let Some(segment_to_insert_above) = ws - .stacks - .iter() - .flat_map(|stack| stack.segments.iter()) - .find_map(|segment| { - segment - .commits - .iter() - .flat_map(|c| c.ref_iter()) - .find_map(|ambiguous_rn| { - (ambiguous_rn == rn.as_ref()) - .then_some(segment.ref_name()) - .flatten() - }) - }) + .commit_graph() + .commit_by_ref(rn.as_ref()) + .and_then(|on| ws.find_commit_and_containers(on)) + .filter(|(_, segment)| segment.ref_name() != Some(rn.as_ref())) + .and_then(|(_, segment)| segment.ref_name()) { match ws_mut.insert_new_segment_above_anchor_if_not_present( rn.as_ref(), @@ -679,44 +526,24 @@ pub fn apply( // Note that this is the exception, typically using stacks will be fine. let existing_stacks_superseded_by_branch = find_superseded_stacks(branch.as_ref(), &ws, &mut ws_md); - merge_result = WorkspaceCommit::from_new_merge_with_metadata( - filter_superseded_metadata_stacks( - ws_md.stacks.iter(), - &existing_stacks_superseded_by_branch, - ), - filter_superseded_anon_stacks( - anon_stacks(&ws.stacks), - &existing_stacks_superseded_by_branch, - ), - &ws, - &in_memory_repo, - Some(branch.as_ref()), - )?; - ensure_no_missing_stacks(&merge_result)?; - - if merge_result.has_conflicts() && on_workspace_conflict.should_abort() { - let conflicting_stacks = - correlate_conflicting_stacks(&ws_md, &merge_result.conflicting_stacks); - return Ok(Outcome { - workspace: ws, - status: OutcomeStatus::ConflictAborted, - workspace_ref_created: false, - workspace_merge: Some(merge_result), - conflicting_stacks, - applied_branches: Vec::new(), - }); - } - new_head_id = merge_result.workspace_commit_id; - conflicting_stacks = correlate_conflicting_stacks(&ws_md, &merge_result.conflicting_stacks); - remove_conflicting_stacks_from_workspace(&mut ws_md, &conflicting_stacks); - let ws_md_override = Some((workspace_ref_name_to_update.clone(), (*ws_md).clone())); - ws = ws.redo( + let merged = match merge_workspace_and_redo( + ws, + &mut ws_md, + &overlay, &in_memory_repo, meta, - overlay - .with_entrypoint(new_head_id, Some(workspace_ref_name_to_update.clone())) - .with_workspace_metadata_override(ws_md_override), - )?; + branch.as_ref(), + &workspace_ref_name_to_update, + on_workspace_conflict, + &existing_stacks_superseded_by_branch, + )? { + MergeAttempt::Aborted(outcome) => return Ok(outcome), + MergeAttempt::Merged(merged) => merged, + }; + merge_result = merged.merge_result; + new_head_id = merged.new_head_id; + conflicting_stacks = merged.conflicting_stacks; + ws = merged.ws; let unapplied_branches = collect_unapplied_branches(&ws); if !unapplied_branches.is_empty() { @@ -771,6 +598,273 @@ pub fn apply( }) } +/// The validated branch to apply: `branch` resolved to its local tracking name when a remote branch +/// was given, its on-disk `branch_ref` (if any), and whether the given branch was a remote-tracking +/// ref with no local tracking ref. +struct ResolvedBranch<'repo> { + branch: gix::refs::FullName, + branch_ref: Option>, + incoming_branch_is_remote_tracking_without_local_tracking: bool, +} + +/// Resolve the branch to apply (remoteโ†’local tracking name) and run the cheap validations. Returns +/// a ready-to-return [`Outcome`] via `Break` for the two short-circuits โ€” an already-applied branch, +/// or one the workspace encloses but hasn't checked out โ€” and bails on an unborn workspace ref, a +/// workspace commit that isn't at the top, or a ref that is itself a workspace. `Continue` yields the +/// workspace and the resolved branch. +fn resolve_and_validate<'repo>( + branch: &FullNameRef, + ws: but_graph::Workspace, + repo: &'repo gix::Repository, + meta: &mut impl RefMetadata, + order: Option, + new_stack_id: fn(&FullNameRef) -> StackId, +) -> anyhow::Result)>> { + let (mut branch_ref, mut incoming_branch_is_remote_tracking_without_local_tracking) = + (try_find_validated_ref(repo, branch, "apply")?, false); + if ws.is_target_or_its_local_tracking(branch) { + bail!("Cannot add the target '{branch}' branch to its own workspace"); + } + let mut branch = branch.to_owned(); + if branch + .category() + .is_some_and(|c| c == Category::RemoteBranch) + { + // TODO(gix): we really want to have a function to return the local tracking branch + // fix this in other places, too. + let Some((upstream_branch_name, _remote_name)) = + repo.upstream_branch_and_remote_for_tracking_branch(branch.as_ref())? + else { + // TODO: actually create a local tracking branch with proper configuration. + bail!("Couldn't find remote refspecs that would match {branch}"); + }; + // Pretend the upstream branch is also the local tracking name. + incoming_branch_is_remote_tracking_without_local_tracking = true; + branch = upstream_branch_name; + branch_ref = try_find_validated_ref(repo, branch.as_ref(), "apply")?; + } + let branch_has_applied_metadata = + branch_has_applied_workspace_metadata(branch.as_ref(), &ws, meta)?; + let branch_already_applied = + ws.is_reachable_from_entrypoint(branch.as_ref()) && branch_has_applied_metadata; + if branch_already_applied { + // When exiting early, don't try to adjust the ws commit. + return Ok(ControlFlow::Break(Outcome { + workspace: ws, + status: OutcomeStatus::AlreadyApplied, + workspace_ref_created: false, + workspace_merge: None, + conflicting_stacks: Vec::new(), + applied_branches: Vec::new(), + })); + } + if !branch_has_applied_metadata && ws.refname_is_segment(branch.as_ref()) { + // The workspace encloses the desired branch, but it's not checked out yet. + return checkout_enclosed_branch(ws, repo, meta, branch.as_ref(), order, new_stack_id) + .map(ControlFlow::Break); + } + + if let Some(ws_ref_name) = ws.ref_name() + && repo.try_find_reference(ws_ref_name)?.is_none() + { + // The workspace is the probably ad-hoc, and doesn't exist, *assume* unborn. + bail!( + "Cannot create reference on unborn branch '{}'", + ws_ref_name.shorten() + ); + } + + crate::branch::ensure_workspace_commit_at_top(&ws, repo)?; + + if meta.workspace_opt(branch.as_ref())?.is_some() { + bail!( + "Refusing to apply a reference that already is a workspace: '{}'", + branch.shorten() + ); + } + Ok(ControlFlow::Continue(( + ws, + ResolvedBranch { + branch, + branch_ref, + incoming_branch_is_remote_tracking_without_local_tracking, + }, + ))) +} + +/// Handle the case where the workspace already encloses `branch` but it isn't checked out yet: +/// check out the workspace tip, record the branch as a stack in the workspace metadata, redo the +/// workspace, and point HEAD at it. Only reached with the branch's applied metadata missing (the +/// caller guards on that), so the metadata repair is unconditional. Returns the finished [`Outcome`]. +fn checkout_enclosed_branch( + ws: but_graph::Workspace, + repo: &gix::Repository, + meta: &mut impl RefMetadata, + branch: &FullNameRef, + order: Option, + new_stack_id: fn(&FullNameRef) -> StackId, +) -> anyhow::Result { + let commit_to_checkout = ws + .tip_commit_id() + .context("Workspace must point to a commit to check out")?; + let ws_ref_name = ws.ref_name().map(|rn| rn.to_owned()); + but_core::worktree::safe_checkout_from_head( + commit_to_checkout, + repo, + but_core::worktree::checkout::Options { + skip_head_update: true, + ..Default::default() + }, + )?; + let applied_branches = vec![branch.to_owned()]; + // The applied metadata is missing here, so record the branch as a stack and persist. Scoped so + // the required-`ws_ref_name` unwrap doesn't shadow the `Option` the redo/set-head below need. + { + let ws_ref_name = ws_ref_name + .as_ref() + .context("Workspace metadata must be available to repair stale applied state")?; + let mut ws_md = meta.workspace(ws_ref_name.as_ref())?; + add_branch_as_stack_forcefully(&mut ws_md, branch, order, new_stack_id); + persist_metadata_and_gitconfig(meta, &applied_branches, &ws_md, None)?; + } + let ws = ws.redo( + repo, + meta, + Overlay::default().with_entrypoint(commit_to_checkout, ws_ref_name.clone()), + )?; + set_head_to_reference( + repo, + commit_to_checkout, + ws_ref_name.as_ref().map(|rn| rn.as_ref()), + )?; + Ok(Outcome { + workspace: ws, + status: OutcomeStatus::Applied, + workspace_ref_created: false, + workspace_merge: None, + conflicting_stacks: Vec::new(), + applied_branches, + }) +} + +/// Demote metadata stacks that should no longer sit in the workspace, then force-add the branches +/// being applied. A stack is demoted when it is stale AdHoc metadata (absent from the reconciled +/// view โ€” AdHoc only, since a managed workspace's metadata is authoritative) or when re-rooting +/// around a checked-out workspace branch keeps only that branch and the ones being applied. +fn restage_metadata_stacks( + ws_md: &mut ref_metadata::Workspace, + ws: &but_graph::Workspace, + branches_to_apply: &[gix::refs::FullName], + head_ref_name: Option<&gix::refs::FullName>, + head_branch_in_workspace: bool, + order: Option, + new_stack_id: fn(&FullNameRef) -> StackId, +) { + // The view, not the pruned display, so a stack hidden by display pruning isn't mistaken for stale. + let projected_refs = matches!(ws.kind, WorkspaceKind::AdHoc).then(|| { + ws.segment_graph + .segment_names() + .map(|rn| rn.as_bstr()) + .collect::>() + }); + for stack in &mut ws_md.stacks { + let dropped_from_projection = projected_refs.as_ref().is_some_and(|projected| { + !stack + .branches + .iter() + .any(|b| projected.contains(b.ref_name.as_ref().as_bstr())) + }); + let stack_is_kept = stack.branches.iter().any(|b| { + branches_to_apply + .iter() + .any(|rn| rn.as_ref() == b.ref_name.as_ref()) + || head_ref_name.is_some_and(|head| head.as_ref() == b.ref_name.as_ref()) + }); + if dropped_from_projection || (head_branch_in_workspace && !stack_is_kept) { + stack.workspacecommit_relation = Outside; + } + } + for rn in branches_to_apply { + add_branch_as_stack_forcefully(ws_md, rn.as_ref(), order, new_stack_id); + } +} + +/// The workspace produced by a successful [`merge_workspace_and_redo`] pass. +struct MergedWorkspace { + merge_result: crate::commit::merge::Outcome, + new_head_id: gix::ObjectId, + conflicting_stacks: Vec, + ws: but_graph::Workspace, +} + +/// The result of one workspace-merge pass: a ready-to-return conflict abort, or a merge. +enum MergeAttempt { + /// Conflicts aborted the merge; the `Outcome` is ready to return from [`apply`]. + Aborted(Outcome), + /// The merge produced a new workspace commit. + Merged(MergedWorkspace), +} + +/// One workspace-merge pass, shared by [`apply`]'s first attempt and its dependent-branch retry: +/// merge the superseded-filtered stacks, bail on missing stacks, then either abort on conflict +/// (per `on_conflict`) or correlate + drop the conflicting stacks and redo the workspace around the +/// new merge commit. Extracted so the two passes can't drift. `base_overlay`'s entrypoint and +/// workspace-metadata override are replaced each pass (both keyed on `workspace_ref_name`, so a +/// fresh pass from the base is equivalent to chaining onto the previous one). +#[expect(clippy::too_many_arguments)] +fn merge_workspace_and_redo( + ws: but_graph::Workspace, + ws_md: &mut ref_metadata::Workspace, + base_overlay: &Overlay, + in_memory_repo: &gix::Repository, + meta: &mut impl RefMetadata, + branch: &FullNameRef, + workspace_ref_name: &gix::refs::FullName, + on_conflict: OnWorkspaceMergeConflict, + superseded: &[StackTip], +) -> anyhow::Result { + let merge_result = WorkspaceCommit::from_new_merge_with_metadata( + filter_superseded_metadata_stacks(ws_md.stacks.iter(), superseded), + filter_superseded_anon_stacks(anon_stacks(&ws.segment_graph), superseded), + &ws, + in_memory_repo, + Some(branch), + )?; + ensure_no_missing_stacks(&merge_result)?; + + if merge_result.has_conflicts() && on_conflict.should_abort() { + let conflicting_stacks = + correlate_conflicting_stacks(ws_md, &merge_result.conflicting_stacks); + return Ok(MergeAttempt::Aborted(Outcome { + workspace: ws, + status: OutcomeStatus::ConflictAborted, + workspace_ref_created: false, + workspace_merge: Some(merge_result), + conflicting_stacks, + applied_branches: Vec::new(), + })); + } + + let new_head_id = merge_result.workspace_commit_id; + let conflicting_stacks = correlate_conflicting_stacks(ws_md, &merge_result.conflicting_stacks); + remove_conflicting_stacks_from_workspace(ws_md, &conflicting_stacks); + let ws_md_override = Some((workspace_ref_name.clone(), (*ws_md).clone())); + let ws = ws.redo( + in_memory_repo, + meta, + base_overlay + .clone() + .with_entrypoint(new_head_id, Some(workspace_ref_name.clone())) + .with_workspace_metadata_override(ws_md_override), + )?; + Ok(MergeAttempt::Merged(MergedWorkspace { + merge_result, + new_head_id, + conflicting_stacks, + ws, + })) +} + /// Map conflicting merge tips back to workspace stack metadata. /// /// Merge conflicts report the tip ref names that could not be merged. This function resolves each @@ -1006,53 +1100,24 @@ fn set_head_to_reference( new_ref_target: gix::ObjectId, new_ref: Option<&gix::refs::FullNameRef>, ) -> anyhow::Result<()> { + use crate::branch::ref_edits; let edits = match new_ref { - None => { - let head_message = "GitButler checkout workspace during apply-branch".into(); - vec![RefEdit { - change: Change::Update { - log: LogChange { - mode: RefLog::AndReference, - force_create_reflog: false, - message: head_message, - }, - expected: PreviousValue::Any, - new: Target::Object(new_ref_target), - }, - name: "HEAD".try_into().expect("well-formed root ref"), - deref: true, - }] - } + None => vec![ref_edits::head_to_commit( + new_ref_target, + "GitButler checkout workspace during apply-branch", + )], Some(new_ref) => { // This also means we want HEAD to point to it. - let head_message = "GitButler switch to workspace during apply-branch".into(); vec![ - RefEdit { - change: Change::Update { - log: LogChange { - mode: RefLog::AndReference, - force_create_reflog: false, - message: head_message, - }, - expected: PreviousValue::Any, - new: Target::Symbolic(new_ref.to_owned()), - }, - name: "HEAD".try_into().expect("well-formed root ref"), - deref: false, - }, - RefEdit { - change: Change::Update { - log: LogChange { - mode: RefLog::AndReference, - force_create_reflog: false, - message: "created by GitButler during apply-branch".into(), - }, - expected: PreviousValue::Any, - new: Target::Object(new_ref_target), - }, - name: new_ref.to_owned(), - deref: false, - }, + ref_edits::head_to_ref( + new_ref, + "GitButler switch to workspace during apply-branch", + ), + ref_edits::ref_to_commit( + new_ref.to_owned(), + new_ref_target, + "created by GitButler during apply-branch", + ), ] } }; diff --git a/crates/but-workspace/src/branch/create_reference.rs b/crates/but-workspace/src/branch/create_reference.rs index db4f9b86274..ebfa4ad7304 100644 --- a/crates/but-workspace/src/branch/create_reference.rs +++ b/crates/but-workspace/src/branch/create_reference.rs @@ -211,16 +211,18 @@ pub(super) mod function { /// checkout-after-create). Placing above a branch that is not the entrypoint leaves the /// entrypoint untouched. /// - /// Return a regenerated Graph that contains the new reference, and from which a new workspace can be derived. - pub fn create_reference<'ws, 'name, T: RefMetadata>( + /// Return a regenerated substrate that contains the new reference, or `None` if the + /// reference already was a workspace segment and nothing changed โ€” the caller's + /// workspace remains current in that case. + pub fn create_reference<'name, T: RefMetadata>( ref_name: impl Borrow, anchor: impl Into>>, repo: &gix::Repository, - workspace: &'ws but_graph::Workspace, + workspace: &but_graph::Workspace, meta: &mut T, new_stack_id: impl FnOnce(&gix::refs::FullNameRef) -> StackId, order: impl Into>, - ) -> anyhow::Result> { + ) -> anyhow::Result> { let anchor = anchor.into(); let order = order.into(); @@ -235,8 +237,8 @@ pub(super) mod function { .try_find_reference(ref_name)? .map(|mut reference| reference.peel_to_id().map(|id| id.detach())) .transpose()?; - let existing_ref_target_in_workspace = existing_ref_target_id - .filter(|id| workspace.find_owner_indexes_by_commit_id(*id).is_some()); + let existing_ref_target_in_workspace = + existing_ref_target_id.filter(|id| workspace.find_commit_and_containers(*id).is_some()); let AnchorResolution { target_id: ref_target_id, @@ -251,7 +253,7 @@ pub(super) mod function { .find_segment_and_stack_by_refname(ref_name) .is_some() { - return Ok(Cow::Borrowed(workspace)); + return Ok(None); } if let Some(existing_ref_target_id) = existing_ref_target_in_workspace { let instruction = existing_ws_meta @@ -306,9 +308,12 @@ pub(super) mod function { position, }) => { let mut validate_id = true; - let indexes = workspace.try_find_owner_indexes_by_commit_id(commit_id)?; - let ref_target_id = - position.resolve_commit(workspace.lookup_commit(indexes).into(), ws_base)?; + let (stack, _seg) = workspace.try_find_commit_and_containers(commit_id)?; + let commit = workspace + .commit_graph() + .node(commit_id) + .context("BUG: a contained commit is in the graph")?; + let ref_target_id = position.resolve_commit(commit.into(), ws_base)?; let id_out_of_workspace = Some(ref_target_id) == ws_base; if id_out_of_workspace { validate_id = false @@ -318,13 +323,7 @@ pub(super) mod function { .as_ref() .filter(|_| !id_out_of_workspace) .map(|_| instruction_by_named_anchor_for_commit(workspace, commit_id)) - .or_else(|| { - let (stack_idx, _seg_idx, _cidx) = indexes; - workspace.stacks[stack_idx] - .id - .map(Instruction::DependentInStack) - .map(Ok) - }) + .or_else(|| stack.id.map(Instruction::DependentInStack).map(Ok)) .transpose()?; AnchorResolution::positioned(ref_target_id, validate_id, instruction) @@ -333,11 +332,21 @@ pub(super) mod function { let mut validate_id = true; let ref_target_id = if workspace.has_metadata() { let resting_id = workspace.try_branch_resting_commit_id(ref_name.as_ref())?; - let commit = workspace - .commit_graph() - .node(resting_id) - .context("BUG: the resting commit is part of the graph")?; - let id = position.resolve_commit(commit.into(), ws_base)?; + // An EMPTY anchor rests on the segment below it โ€” the new reference + // shares that commit for either position, only the ordering differs. + // Stepping to the parent would skip that segment's whole territory. + let anchor_is_empty = workspace + .find_segment_and_stack_by_refname(ref_name.as_ref()) + .is_some_and(|(_, segment)| segment.tip().is_none()); + let id = if anchor_is_empty { + resting_id + } else { + let commit = workspace + .commit_graph() + .node(resting_id) + .context("BUG: the resting commit is part of the graph")?; + position.resolve_commit(commit.into(), ws_base)? + }; if Some(id) == ws_base { validate_id = false } @@ -351,14 +360,14 @@ pub(super) mod function { ref_name.shorten() ); }; - position.resolve_commit( - segment - .commits - .first() - .context("Cannot create reference on unborn branch")? - .into(), - ws_base, - )? + let tip = segment + .tip() + .context("Cannot create reference on unborn branch")?; + let commit = workspace + .commit_graph() + .node(tip) + .context("BUG: a segment tip is in the graph")?; + position.resolve_commit(commit.into(), ws_base)? }; AnchorResolution::positioned( ref_target_id, @@ -419,7 +428,7 @@ pub(super) mod function { .transpose()?; // Assure this commit is in the workspace as well. if check_if_id_in_workspace { - workspace.try_find_owner_indexes_by_commit_id(ref_target_id)?; + workspace.try_find_commit_and_containers(ref_target_id)?; } let updated_workspace = { @@ -536,7 +545,7 @@ pub(super) mod function { update_branch_metadata(ref_name, repo, &mut branch_md)?; meta.set_branch(&branch_md)?; - Ok(Cow::Owned(updated_workspace)) + Ok(Some(updated_workspace)) } /// Resolve an [`Anchor::AtReference`] in an ad-hoc (single-branch) workspace against a local @@ -690,6 +699,7 @@ pub(super) mod function { .push(WorkspaceStackBranch { ref_name: new_ref.to_owned(), archived: false, + parents: None, }); } // create new @@ -700,6 +710,7 @@ pub(super) mod function { branches: vec![WorkspaceStackBranch { ref_name: new_ref.to_owned(), archived: false, + parents: None, }], }; @@ -736,6 +747,7 @@ pub(super) mod function { WorkspaceStackBranch { ref_name: new_ref.to_owned(), archived: false, + parents: None, }, ); } @@ -751,46 +763,28 @@ pub(super) mod function { ws: &but_graph::Workspace, anchor_id: gix::ObjectId, ) -> anyhow::Result> { - use Position::*; - let (anchor_stack_idx, anchor_seg_idx, _anchor_commit_idx) = ws - .find_owner_indexes_by_commit_id(anchor_id) - .with_context(|| { - format!( - "No segment in workspace at '{}' that holds {anchor_id}", - ws.ref_name_display() - ) - })?; - - let stack = &ws.stacks[anchor_stack_idx]; - // Find first non-empty segment in this stack upward and downward. - let instruction = (0..anchor_seg_idx + 1) - .rev() - .find_map(|seg_idx| { - let s = &stack.segments[seg_idx]; - s.ref_name() - .map(|rn| (rn, Below)) - .filter(|_| s.metadata.is_some()) - }) - .or_else(|| { - (anchor_seg_idx + 1..stack.segments.len()).find_map(|seg_idx| { - let s = &stack.segments[seg_idx]; - s.ref_name() - .map(|rn| (rn, Above)) - .filter(|_| s.metadata.is_some()) - }) - }) - .map(|(anchor_ref, position)| Instruction::Dependent { + let (stack, _seg) = ws.find_commit_and_containers(anchor_id).with_context(|| { + format!( + "No segment in workspace at '{}' that holds {anchor_id}", + ws.ref_name_display() + ) + })?; + // The nearest named+metadata segment (below the anchor first, then above) is the branch the + // new dependent reference orders against; with none, create the first branch directly. + let instruction = ws + .nearest_named_metadata_segment(anchor_id) + .map(|(anchor_ref, below)| Instruction::Dependent { ref_name: Cow::Owned(anchor_ref.to_owned()), - position, - }) - .unwrap_or( - // Not a single name? It's empty, or branch metadata is missing. - // Create the first branch (then with metadata) directly. - match stack.id { - None => Instruction::Independent, - Some(id) => Instruction::DependentInStack(id), + position: if below { + Position::Below + } else { + Position::Above }, - ); + }) + .unwrap_or(match stack.id { + None => Instruction::Independent, + Some(id) => Instruction::DependentInStack(id), + }); Ok(instruction) } diff --git a/crates/but-workspace/src/branch/integrate_branch_upstream/mod.rs b/crates/but-workspace/src/branch/integrate_branch_upstream/mod.rs index b114033c83c..928b2cb6f9c 100644 --- a/crates/but-workspace/src/branch/integrate_branch_upstream/mod.rs +++ b/crates/but-workspace/src/branch/integrate_branch_upstream/mod.rs @@ -7,6 +7,7 @@ use anyhow::{Result, bail}; use but_core::{RefMetadata, commit::Headers}; use but_rebase::graph_rebase::{ Editor, LookupStep, SuccessfulRebase, ToSelector, + mutate::Reconnect, selector::{SelectorSet, StepRange}, }; @@ -184,7 +185,12 @@ pub fn integrate_branch_with_steps<'meta, M: RefMetadata>( .collect::>>()?; // Step 3: Disconnect the segment, isolating it so that we can freely manipulate it. - editor.disconnect_range_from(range, children_to_disconnect, parents_to_disconnect, true)?; + editor.disconnect_range_from( + range, + children_to_disconnect, + parents_to_disconnect, + Reconnect::Skip, + )?; // Step 4: Based on the prepared steps, we rebuild the chain. let new_segment_delimiter = diff --git a/crates/but-workspace/src/branch/integrate_branch_upstream/plan.rs b/crates/but-workspace/src/branch/integrate_branch_upstream/plan.rs index 13159504a33..cc6ef077607 100644 --- a/crates/but-workspace/src/branch/integrate_branch_upstream/plan.rs +++ b/crates/but-workspace/src/branch/integrate_branch_upstream/plan.rs @@ -3,18 +3,11 @@ use std::collections::{HashMap, HashSet}; use anyhow::{Context as _, Result, bail}; -use bstr::BStr; -use but_core::{ - ChangeId, RefMetadata, RepositoryExt, - commit::{add_conflict_markers, write_conflicted_tree}, -}; -use but_rebase::{ - commit::DateMode, - graph_rebase::{ - Editor, LookupStep, Selector, Step, - merge_commit_changes::MergeCommitChangesOutcome, - selector::{SelectorSet, StepRange}, - }, +use but_core::{ChangeId, RefMetadata, RepositoryExt}; +use but_rebase::graph_rebase::{ + Editor, LookupStep, Selector, Step, + mutate::Reconnect, + selector::{SelectorSet, StepRange}, }; use crate::graph_manipulation::{ @@ -287,41 +280,16 @@ fn prepare_squash_step_for_editor( let tip_commit_id = *ordered_commit_ids .last() .expect("validated non-empty squash commit list"); - let mut squashed_commit = editor.find_commit(tip_commit_id)?; - squashed_commit.inner.parents = vec![squashed_parent].into(); + let squashed_commit = editor.find_commit(tip_commit_id)?; let commit_message = message .map(|message| message.as_bytes().to_vec()) .unwrap_or_else(|| Vec::from(squashed_commit.message.clone())); - apply_merge_commit_changes_outcome( - editor.repo(), - &mut squashed_commit, + editor.new_squashed_commit( + squashed_commit, + vec![squashed_parent], merge_outcome, commit_message, - )?; - - editor.new_commit(squashed_commit, DateMode::CommitterUpdateAuthorKeep) -} - -fn apply_merge_commit_changes_outcome( - repo: &gix::Repository, - commit: &mut but_core::CommitOwned, - outcome: MergeCommitChangesOutcome, - message: Vec, -) -> Result<()> { - if let Some(conflict) = outcome.conflict { - commit.tree = write_conflicted_tree( - repo, - outcome.tree_id, - &conflict.tree_expression, - &conflict.conflict_entries, - )?; - commit.message = add_conflict_markers(BStr::new(&message)); - } else { - commit.tree = outcome.tree_id; - commit.message = message.into(); - } - - Ok(()) + ) } /// Builds and inserts the integrated commit chain under `ref_name` down to the last step. @@ -393,12 +361,8 @@ fn integration_steps_to_segment_steps_for_editor( out.push(existing_or_new_pick_step(editor, *commit_id)?); } PreparedIntegrationStep::Merge { commit_id } => { - let mut merge_commit = editor.empty_commit()?; - merge_commit.message = format!("Merge {commit_id} into previous commit").into(); - let merge_commit = editor.new_commit( - merge_commit, - but_rebase::commit::DateMode::CommitterKeepAuthorKeep, - )?; + let merge_commit = + editor.new_merge_commit(format!("Merge {commit_id} into previous commit"))?; let preserved_parents = editor .find_commit(*commit_id)? .inner @@ -449,7 +413,7 @@ fn existing_or_new_pick_step( }, SelectorSet::None, parents_to_disconnect, - true, + Reconnect::Skip, )?; // The integration rebuilds this commit onto new parents, so it must be diff --git a/crates/but-workspace/src/branch/mod.rs b/crates/but-workspace/src/branch/mod.rs index fdc46c35380..340e62035d8 100644 --- a/crates/but-workspace/src/branch/mod.rs +++ b/crates/but-workspace/src/branch/mod.rs @@ -485,23 +485,27 @@ impl OnWorkspaceMergeConflict { /// projected workspace so the merge builder can insert the anonymous tip at the same parent number, /// and `tip` is the commit/segment pair to merge, with no ref name attached. pub(crate) fn anon_stacks( - stacks: &[but_graph::workspace::Stack], -) -> impl Iterator { - stacks.iter().enumerate().filter_map(|(idx, s)| { - if s.ref_name().is_none() { - s.tip_skip_empty().map(|cid| { - ( - idx, - crate::commit::merge::Seed { - name: None, - commit_id: cid, - }, - ) - }) - } else { - None - } - }) + segment_graph: &but_graph::workspace::SegmentGraph, +) -> impl Iterator + '_ { + segment_graph + .stacks + .iter() + .enumerate() + .filter_map(|(idx, s)| { + if s.top().is_some_and(|seg| seg.ref_name.is_none()) { + s.tip_skip_empty().map(|cid| { + ( + idx, + crate::commit::merge::Seed { + name: None, + commit_id: cid, + }, + ) + }) + } else { + None + } + }) } /// Ensure every metadata stack that should be merged was visible in the graph. @@ -518,6 +522,76 @@ pub(crate) fn ensure_no_missing_stacks( } } +/// The standard GitButler reference-transaction edits apply and unapply build their HEAD and +/// workspace-ref moves from. All use `PreviousValue::Any` โ€” HEAD moves are not optimistically +/// locked (unchanged, long-standing behavior). +pub(crate) mod ref_edits { + use gix::refs::{ + FullName, FullNameRef, Target, + transaction::{Change, LogChange, PreviousValue, RefEdit}, + }; + + fn log(message: &str) -> LogChange { + LogChange { + mode: gix::refs::transaction::RefLog::AndReference, + force_create_reflog: false, + message: message.into(), + } + } + + /// Point `HEAD` symbolically at `target`. + pub(crate) fn head_to_ref(target: &FullNameRef, message: &str) -> RefEdit { + RefEdit { + change: Change::Update { + log: log(message), + expected: PreviousValue::Any, + new: Target::Symbolic(target.to_owned()), + }, + name: "HEAD".try_into().expect("well-formed root ref"), + deref: false, + } + } + + /// Point `HEAD` (through its referent) at `id` โ€” the detached/checkout form. + pub(crate) fn head_to_commit(id: gix::ObjectId, message: &str) -> RefEdit { + RefEdit { + change: Change::Update { + log: log(message), + expected: PreviousValue::Any, + new: Target::Object(id), + }, + name: "HEAD".try_into().expect("well-formed root ref"), + deref: true, + } + } + + /// Point `name` at `id`. + pub(crate) fn ref_to_commit(name: FullName, id: gix::ObjectId, message: &str) -> RefEdit { + RefEdit { + change: Change::Update { + log: log(message), + expected: PreviousValue::Any, + new: Target::Object(id), + }, + name, + deref: false, + } + } +} + +/// Bail when the workspace commit of `ws` is buried in history rather than at the top โ€” +/// the shared precondition of apply and unapply. +pub(crate) fn ensure_workspace_commit_at_top( + ws: &but_graph::Workspace, + repo: &gix::Repository, +) -> anyhow::Result<()> { + use crate::ref_info::WorkspaceExt as _; + if ws.has_workspace_commit_in_ancestry(repo) { + anyhow::bail!("Refusing to work on workspace whose workspace commit isn't at the top"); + } + Ok(()) +} + /// Find `branch` in `repo` and reject it if it resolves to a symbolic reference. /// /// `operation` is used only for the error message so callers such as apply and unapply can share diff --git a/crates/but-workspace/src/branch/move_branch.rs b/crates/but-workspace/src/branch/move_branch.rs index 4077b2055fe..cab5b19b12f 100644 --- a/crates/but-workspace/src/branch/move_branch.rs +++ b/crates/but-workspace/src/branch/move_branch.rs @@ -24,12 +24,62 @@ pub struct Outcome<'meta, M: RefMetadata> { pub branch_stack_order: Option>, } +/// What remains after [`Outcome::apply()`]: the pieces callers still need once the move is +/// on disk and the workspace refreshed. +pub struct Applied<'meta, M: RefMetadata> { + /// The metadata handle, released by materialization. + pub meta: &'meta mut M, + /// Every commit rewritten by the move, old id to new id. + pub commit_mappings: std::collections::BTreeMap, + /// See [`Outcome::new_tip`]: the reference the caller should check out in single-branch + /// mode, `None` when the tip is unchanged. Applying does NOT move `HEAD`. + pub new_tip: Option, +} + +impl<'meta, M: RefMetadata> Outcome<'meta, M> { + /// Apply the move for real, in one step: materialize the rebase to disk, persist the + /// branch order and workspace metadata it decided, and refresh `ws` from the result. + /// + /// Metadata and refs move together here โ€” persisting one without the other leaves a + /// projection that disagrees with disk. Dry runs preview via + /// [`SuccessfulRebase::rebase_overlay_with_workspace_overrides`] instead and never call + /// this. + pub fn apply( + self, + ws: &mut but_graph::Workspace, + repo: &gix::Repository, + ) -> anyhow::Result> { + let Outcome { + rebase, + ws_meta, + new_tip, + branch_stack_order, + } = self; + let materialized = rebase.materialize()?; + if let Some(order) = branch_stack_order.as_deref() { + materialized.meta.set_branch_stack_order(order)?; + } + ws.refresh_from_commit_graph(materialized.arena().clone(), repo, materialized.meta)?; + if let Some((ws_meta, ref_name)) = ws_meta.zip(ws.ref_name()) { + let mut md = materialized.meta.workspace(ref_name)?; + *md = ws_meta; + md.set_project_meta(ws.project_meta().clone()); + materialized.meta.set_workspace(&md)?; + } + Ok(Applied { + commit_mappings: materialized.history.commit_mappings(), + meta: materialized.meta, + new_tip, + }) + } +} + pub(super) mod function { use but_core::RefMetadata; use but_core::ref_metadata::StackId; - use but_rebase::graph_rebase::mutate::InsertSide; - use but_rebase::graph_rebase::selector::{SelectorSet, SomeSelectors, StepRange}; + use but_rebase::graph_rebase::mutate::{InsertSide, Reconnect}; + use but_rebase::graph_rebase::selector::SomeSelectors; use crate::graph_manipulation::DisconnectParameters; use crate::graph_manipulation::get_disconnect_parameters; @@ -132,7 +182,7 @@ pub(super) mod function { subject_delimiter.clone(), children_to_disconnect, parents_to_disconnect, - false, + Reconnect::Heal, )?; let selectors = SomeSelectors::new(vec![head_selector])?; @@ -201,7 +251,7 @@ pub(super) mod function { match &workspace.kind { WorkspaceKind::AdHoc => move_branch_in_single_branch_mode( successful_rebase, - workspace, + workspace.ref_name_owned(), source, destination, subject_branch_name, @@ -232,7 +282,7 @@ pub(super) mod function { /// skip persistence for dry-run previews. fn move_branch_in_single_branch_mode<'meta, M: RefMetadata>( mut successful_rebase: SuccessfulRebase<'meta, M>, - workspace: but_graph::Workspace, + entrypoint: Option, source: WorkspaceSegmentContext, destination: WorkspaceSegmentContext, subject_branch_name: &FullNameRef, @@ -240,25 +290,21 @@ pub(super) mod function { ) -> anyhow::Result> { let (source_stack, subject_segment) = &source; let (destination_stack, _) = &destination; - let entrypoint = workspace.ref_name().map(ToOwned::to_owned); // A branch that owns commits can only be reordered within its current stack in // single-branch mode. Moving it across stacks would change commit ownership and needs a // real rebase. - if !subject_segment.commits.is_empty() && !same_stack(source_stack, destination_stack) { + if subject_segment.tip().is_some() && !same_stack(source_stack, destination_stack) { bail!("Moving a non-empty branch in single-branch mode is not yet supported"); } - // Reordering same-target empty refs only changes which empty segment is displayed first. - // If their targets differ, however, the subject crosses commit-owning segments and its ref - // must move with it or those commits would be projected as belonging to the empty branch. - // When the subject and target rest on the same commit, no commit relocates: the subject - // (the commit owner) stays put and the empty segments that sit above it on that shared - // commit drop onto its parent commit. Moving the subject's own commit range in that case is - // a degenerate, self-referential move that collides the shared-commit refs. + // Reordering same-target empty refs only changes which empty segment is displayed first โ€” + // metadata alone covers it. Everything else needs the editor: the crossed refs (and + // possibly commits) must move to match the new order, or the projection would disagree + // with what was persisted. let subject_shares_target_commit = successful_rebase .reference_target(subject_branch_name)? == successful_rebase.reference_target(target_branch_name)?; let move_requires_graph_update = - !subject_segment.commits.is_empty() || !subject_shares_target_commit; + subject_segment.tip().is_some() || !subject_shares_target_commit; let existing_order = { let (_repo, meta) = successful_rebase.repo_and_meta_mut(); if !meta.can_persist_branch_stack_order() { @@ -307,60 +353,16 @@ pub(super) mod function { } if move_requires_graph_update { - let segment_index = |name: &FullNameRef| { - source_stack - .segments - .iter() - .position(|segment| segment.ref_name() == Some(name)) - }; - let subject_index = segment_index(subject_branch_name) - .context("BUG: subject segment missing from its source stack")?; - let target_index = segment_index(target_branch_name) - .context("BUG: target segment missing from the source stack")?; - - // The same-commit case: the subject stays, and the empty segments between it and the - // target (which all share its tip) drop onto the subject's parent commit โ€” the base. - let between = source_stack - .segments - .get(target_index..subject_index) - .unwrap_or(&[]); - let empties_drop_below_subject = subject_shares_target_commit - && target_index < subject_index - && between.iter().all(|segment| segment.commits.is_empty()); + // The subject's commits relocate only when the move crosses another commit owner. + // Crossing empties alone moves no commit: the subject's range already rests where + // it lands, and only the empty refs need re-pointing (the reconcile pass below). + let crossed = crossed_segments(source_stack, subject_branch_name, target_branch_name)?; + let needs_commit_surgery = subject_segment.tip().is_some() + && crossed.iter().any(|segment| segment.tip().is_some()); let mut editor = successful_rebase.into_editor(); - if empties_drop_below_subject { - // Re-anchor each empty into the base commit's group (above the base owner just - // below the subject), bottom to top, so it re-points onto the base and the group - // keeps its order below the subject. A single-entry reference range unhooks the - // empty from its current position without touching any commit. - let mut anchor_ref = source_stack - .segments - .get(subject_index + 1) - .and_then(|segment| segment.ref_name()) - .context("No base segment below the subject to re-anchor empties onto")? - .to_owned(); - for segment in between.iter().rev() { - let empty_ref = segment - .ref_name() - .context("Empty segment doesn't have a ref")?; - let empty = editor.select_reference(empty_ref)?; - let range = StepRange { - child: empty, - parent: empty, - }; - editor.disconnect_range_from( - range.clone(), - SelectorSet::All, - SelectorSet::All, - false, - )?; - let anchor = editor.select_reference(anchor_ref.as_ref())?; - editor.insert_range(anchor, range, InsertSide::Above)?; - anchor_ref = empty_ref.to_owned(); - } - } else { + if needs_commit_surgery { let target_selector = editor .select_reference(target_branch_name) .context("Failed to find target reference in graph.")?; @@ -373,11 +375,38 @@ pub(super) mod function { subject_delimiter.clone(), children_to_disconnect, parents_to_disconnect, - false, + Reconnect::Heal, )?; editor.insert_range(target_selector, subject_delimiter, InsertSide::Above)?; } + // Reconcile pass: re-anchor every empty ref of this stack directly above its + // below-neighbor per the new order, bottom to top. This is the one mechanism all + // empty-crossing shapes reduce to โ€” empties dropping below a lifted subject, + // lifting above a lowered one, an empty subject relocating itself, and empties + // that rode a surgically moved commit โ€” so refs always land where the persisted + // order says. + let mut anchor_ref: Option = None; + for name in new_order.iter().rev() { + let is_empty_segment = segment_index_in(source_stack, name.as_ref()) + .and_then(|index| source_stack.segments.get(index)) + .map(|segment| segment.tip().is_none()); + if is_empty_segment != Some(true) { + // A commit owner, or a segment this stack doesn't know: a fixed anchor. + anchor_ref = Some(name.clone()); + continue; + } + let anchor = anchor_ref.as_ref().with_context(|| { + format!( + "Unsupported reorder: empty branch '{}' would sit below the bottom branch", + name.as_bstr() + ) + })?; + let anchor = editor.select_reference(anchor.as_ref())?; + editor.move_reference(name.as_ref(), anchor, InsertSide::Above)?; + anchor_ref = Some(name.clone()); + } + return Ok(Outcome { rebase: editor.rebase()?, ws_meta: None, @@ -413,9 +442,19 @@ pub(super) mod function { } let (source_stack, subject_segment) = source; - let (_, target_segment) = destination; - if subject_segment.commits.is_empty() - && target_segment.commits.is_empty() + let (destination_stack, target_segment) = destination; + // Same-commit empty refs within ONE stack reorder purely in metadata. Empties on + // different commits are a real move (the subject's ref must re-point), and a + // same-commit move ACROSS stacks must still merge the subject into the target's + // chain in the editor โ€” a metadata-only edit leaves the subject's old chain in + // the layout, and the next materialize builds a stale workspace parent from it. + let subject_shares_target_commit = successful_rebase + .reference_target(subject_branch_name)? + == successful_rebase.reference_target(target_branch_name)?; + if subject_segment.tip().is_none() + && target_segment.tip().is_none() + && subject_shares_target_commit + && same_stack(&source_stack, &destination_stack) && ws_meta.is_some() { if let Some(ws_meta) = ws_meta.as_mut() { @@ -429,6 +468,61 @@ pub(super) mod function { }); } + // A commit-owning subject crossing only empties within its stack relocates nothing: + // its range already rests where it lands. Running the pick surgery anyway is the + // degenerate self-move โ€” it disconnects the range from the workspace commit and never + // reconnects it, expelling the subject's commits from the workspace. Re-anchor the + // crossed empty refs around the unchanged range instead. + if subject_segment.tip().is_some() && same_stack(&source_stack, &destination_stack) { + let crossed = crossed_segments(&source_stack, subject_branch_name, target_branch_name)?; + let target_index = segment_index_in(&source_stack, target_branch_name) + .context("BUG: target segment missing from the source stack")?; + let subject_index = segment_index_in(&source_stack, subject_branch_name) + .context("BUG: subject segment missing from its source stack")?; + if crossed.iter().all(|segment| segment.tip().is_none()) { + let mut editor = successful_rebase.into_editor(); + if target_index < subject_index { + // The subject lifts above the crossed empties: they drop below its + // bottom-most commit, keeping their order. + let mut anchor = editor.select_commit( + subject_segment + .commits + .last() + .copied() + .context("BUG: non-empty subject has commits")?, + )?; + for segment in crossed { + let empty_ref = segment + .ref_name() + .context("Empty segment doesn't have a ref")?; + editor.move_reference(empty_ref, anchor, InsertSide::Below)?; + anchor = editor.select_reference(empty_ref)?; + } + } else { + // The subject drops below the crossed empties: they lift above its tip + // ref, keeping their order. + let mut anchor_ref = subject_branch_name.to_owned(); + for segment in crossed.iter().rev() { + let empty_ref = segment + .ref_name() + .context("Empty segment doesn't have a ref")?; + let anchor = editor.select_reference(anchor_ref.as_ref())?; + editor.move_reference(empty_ref, anchor, InsertSide::Above)?; + anchor_ref = empty_ref.to_owned(); + } + } + if let Some(ws_meta) = ws_meta.as_mut() { + move_branch_in_metadata(ws_meta, subject_branch_name, target_branch_name); + } + return Ok(Outcome { + rebase: editor.rebase()?, + ws_meta, + new_tip: None, + branch_stack_order: None, + }); + } + } + let mut editor = successful_rebase.into_editor(); let target_segment_ref_name = target_segment .ref_name() @@ -448,12 +542,16 @@ pub(super) mod function { Some(workspace_head), )?; - let skip_reconnect_step = source_stack.segments.len() == 1; + let reconnect = if source_stack.segments.len() == 1 { + Reconnect::Skip + } else { + Reconnect::Heal + }; editor.disconnect_range_from( subject_delimiter.clone(), children_to_disconnect, parents_to_disconnect, - skip_reconnect_step, + reconnect, )?; editor.insert_range( target_selector, @@ -475,22 +573,60 @@ pub(super) mod function { }) } + /// The position of the segment named `name` within `stack`, if any. + fn segment_index_in( + stack: &but_graph::workspace::SegmentStack, + name: &FullNameRef, + ) -> Option { + stack + .segments + .iter() + .position(|segment| segment.ref_name() == Some(name)) + } + + /// The segments a reorder of `subject` onto `target` crosses within `stack`: strictly + /// between the two, in stack order, exclusive of both. Shared by both move modes. + fn crossed_segments<'a>( + stack: &'a but_graph::workspace::SegmentStack, + subject_branch_name: &FullNameRef, + target_branch_name: &FullNameRef, + ) -> anyhow::Result<&'a [but_graph::workspace::Segment]> { + let subject_index = segment_index_in(stack, subject_branch_name) + .context("BUG: subject segment missing from its source stack")?; + let target_index = segment_index_in(stack, target_branch_name) + .context("BUG: target segment missing from the source stack")?; + Ok(if target_index < subject_index { + stack + .segments + .get(target_index..subject_index) + .unwrap_or(&[]) + } else { + stack + .segments + .get(subject_index + 1..target_index) + .unwrap_or(&[]) + }) + } + /// A segment and its container stack. type WorkspaceSegmentContext = ( - but_graph::workspace::Stack, - but_graph::workspace::StackSegment, + but_graph::workspace::SegmentStack, + but_graph::workspace::Segment, ); type WorkspaceSegmentContextRef<'a> = ( - &'a but_graph::workspace::Stack, - &'a but_graph::workspace::StackSegment, + &'a but_graph::workspace::SegmentStack, + &'a but_graph::workspace::Segment, ); fn own_context<'a>(ctx: WorkspaceSegmentContextRef<'a>) -> WorkspaceSegmentContext { (ctx.0.to_owned(), ctx.1.to_owned()) } - fn same_stack(left: &but_graph::workspace::Stack, right: &but_graph::workspace::Stack) -> bool { + fn same_stack( + left: &but_graph::workspace::SegmentStack, + right: &but_graph::workspace::SegmentStack, + ) -> bool { left.segments.len() == right.segments.len() && left .segments @@ -499,17 +635,13 @@ pub(super) mod function { .all(|(left, right)| left.ref_name() == right.ref_name()) } - fn stack_branch_order(stack: &but_graph::workspace::Stack) -> Vec { - stack - .segments - .iter() - .filter_map(|segment| segment.ref_name().map(ToOwned::to_owned)) - .collect() + fn stack_branch_order(stack: &but_graph::workspace::SegmentStack) -> Vec { + stack.segment_names().map(ToOwned::to_owned).collect() } fn reordered_entrypoint( entrypoint: Option<&FullNameRef>, - stack: &but_graph::workspace::Stack, + stack: &but_graph::workspace::SegmentStack, new_order: &[gix::refs::FullName], ) -> Option { let entrypoint = entrypoint?; @@ -570,7 +702,7 @@ pub(super) mod function { /// /// Mirrors the [`Position::Above`](crate::branch::create_reference::Position) case of /// `create_reference`'s `insert_into_branch_stack_order`: `subject` is removed and re-inserted - /// at `target`'s slot, pushing `target` (and everything below it) down. + /// at `target`'s position, pushing `target` (and everything below it) down. /// /// If `target` isn't tracked yet (stale or empty metadata) it is appended first, so that a move /// where *both* branches are missing adds them both - `subject` on top of `target` - instead of diff --git a/crates/but-workspace/src/branch/remove_reference.rs b/crates/but-workspace/src/branch/remove_reference.rs index 240e8c8afe8..9e5fa3efa94 100644 --- a/crates/but-workspace/src/branch/remove_reference.rs +++ b/crates/but-workspace/src/branch/remove_reference.rs @@ -23,7 +23,9 @@ use gix::refs::transaction::PreviousValue; /// It's not an error if `ref_name` can't be found. /// Note that the `workspace` will be stale after deleting the reference successfully. /// -/// Return the updated graph that reflects this change, or `None` if nothing changed. +/// Return the updated substrate that reflects this change, or `None` if nothing changed. +/// Display callers materialize via +/// [`into_display`](but_graph::Workspace::into_display). pub fn remove_reference( ref_name: &gix::refs::FullNameRef, repo: &gix::Repository, @@ -40,16 +42,11 @@ pub fn remove_reference( }; if avoid_anonymous_stacks - && (stack - .segments - .iter() - .map(|s| s.commits.len()) - .sum::() - > 0 + && (stack.segments.iter().any(|s| s.tip().is_some()) && stack .segments .iter() - .filter(|s| s.ref_info.is_some()) + .filter(|s| s.ref_name.is_some()) .count() < 2) { @@ -86,22 +83,29 @@ pub fn remove_reference( let stack_id = stack.id; let ws = workspace.redo(repo, meta, Default::default())?; if avoid_anonymous_stacks { - let Some(stack) = ws.stacks.iter().find(|s| s.id == stack_id) else { + let Some(stack) = ws.segment_graph.stack_by_id(stack_id) else { // The whole stack is gone, so nothing that could be anonymous. return Ok(Some(ws)); }; if avoid_anonymous_stacks && let Some(commit) = stack - .segments - .first() - .and_then(|s| s.commits.first().filter(|_| s.ref_info.is_none())) + .top() + .and_then(|s| s.tip().filter(|_| s.ref_name.is_none())) { + // The first named segment below the anonymous tip and its resting commit, + // computed on this same view stack (not a display re-lookup). let (name_of_segment_below, target_id) = stack .segments .iter() - .find_map(|s| { + .enumerate() + .find_map(|(idx, s)| { let rn = s.ref_name()?; - ws.branch_resting_commit_id(rn).map(|id| (rn, id)) + let resting = stack + .segments_at_or_below(idx) + .iter() + .find_map(|seg| seg.tip()) + .or(stack.base)?; + Some((rn, resting)) }) .with_context(|| { "BUG: should not try to delete branch if anon \ @@ -110,7 +114,7 @@ pub fn remove_reference( repo.reference( name_of_segment_below, - commit.id, + commit, PreviousValue::MustExistAndMatch(gix::refs::Target::Object(target_id)), "move segment reference up to avoid anonymous stack", )?; diff --git a/crates/but-workspace/src/branch/unapply.rs b/crates/but-workspace/src/branch/unapply.rs index 637039c5180..e0883c56ab9 100644 --- a/crates/but-workspace/src/branch/unapply.rs +++ b/crates/but-workspace/src/branch/unapply.rs @@ -1,12 +1,12 @@ -use std::borrow::Cow; - /// Returned by [unapply()](function::unapply()). -pub struct Outcome<'workspace> { - /// The updated workspace, if owned, or the one that was passed in if borrowed, to show how the workspace looks after unapplying. - /// - /// If borrowed, the graph already didn't contain the desired branch and nothing had to be unapplied. Note that metadata changes - /// might not be included in this case, as they aren't the source of truth. - pub workspace: Cow<'workspace, but_graph::Workspace>, +pub struct Outcome { + /// The updated workspace substrate, or `None` if the graph already didn't contain the + /// desired branch and nothing had to be unapplied โ€” the caller's workspace remains + /// current then (note that metadata changes might not be included in that case, as they + /// aren't the source of truth). Display callers materialize the pruned shape via + /// [`into_display`](but_graph::Workspace::into_display) (or + /// [`Self::display_workspace`]). + pub workspace: Option, /// The unapply operation ended by checking out this ref. /// /// This is set when the operation switches back to the enclosing workspace ref after unapplying the checked-out stack, @@ -17,28 +17,40 @@ pub struct Outcome<'workspace> { /// /// Unapply does not return conflicted merge outcomes. If rebuilding the workspace merge commit /// conflicts, `unapply()` fails before refs, metadata, index, or worktree are updated. - pub workspace_merge: Option, + /// The rebuilt workspace merge commit, when the unapply re-merged the remaining + /// stack tips (absent when the workspace collapsed, emptied, or was already right). + pub workspace_merge: Option, } -impl<'workspace> Outcome<'workspace> { - fn new(ws: Cow<'workspace, but_graph::Workspace>) -> Self { +impl Outcome { + fn new(ws: Option) -> Self { Outcome { workspace: ws, checked_out: None, workspace_merge: None, } } -} -impl Outcome<'_> { /// Return `true` if a new graph traversal was performed, which always is a sign for an operation which changed the workspace. /// This is `false` if the branch to unapply was already absent from the current workspace. pub fn workspace_changed(&self) -> bool { - matches!(self.workspace, Cow::Owned(_)) + self.workspace.is_some() + } + + /// Materialize the pruned DISPLAY workspace from the carried substrate โ€” for rendering and + /// other display boundaries; operations keep reading the substrate itself. Errors when the + /// unapply was a no-op: the caller's own workspace is the current one then. + pub fn display_workspace(&self) -> anyhow::Result { + use anyhow::Context as _; + Ok(self + .workspace + .as_ref() + .context("unapply was a no-op; the input workspace is unchanged")? + .clone()) } } -impl std::fmt::Debug for Outcome<'_> { +impl std::fmt::Debug for Outcome { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let Outcome { workspace: _, @@ -106,11 +118,9 @@ impl WorkspaceDisposition { } fn may_delete_workspace_reference(self) -> bool { - matches!( - self, - WorkspaceDisposition::PreventUnnecessaryWorkspaceReferences - | WorkspaceDisposition::PreventUnnecessaryWorkspaceReferencesKeepWorkspaceCommit - ) + // Deleting the workspace reference always requires switching away from it, so today the + // two permissions coincide; kept as distinct names for the distinct questions callers ask. + self.may_switch_away_from_workspace() } } @@ -124,23 +134,18 @@ pub struct Options { pub(crate) mod function { use super::{Options, Outcome, WorkspaceDisposition}; use anyhow::{Context as _, bail, ensure}; - use std::borrow::Cow; - use but_core::{ - ObjectStorageExt as _, RefMetadata, RepositoryExt as _, - ref_metadata::{ProjectMeta, ProjectedWorkspaceStack, StackId}, - }; + use but_core::{RefMetadata, ref_metadata::ProjectMeta}; use but_graph::walk::Overlay; use gix::{ prelude::ObjectIdExt, refs::{ FullName, FullNameRef, Target, - transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}, + transaction::{Change, PreviousValue, RefEdit, RefLog}, }, }; - use crate::{WorkspaceCommit, branch::try_find_validated_ref}; - use crate::{branch::anon_stacks, ref_info::WorkspaceExt}; + use crate::branch::try_find_validated_ref; /// Remove `branch` from `workspace`, updating `repo` and `meta` so the resulting workspace is the inverse of applying that branch. /// @@ -188,27 +193,21 @@ pub(crate) mod function { /// the workspace ref is replaced by its target's local branch, or by the named stack with the lowest /// generation (i.e. the topologically 'newest') if there is no target. #[tracing::instrument(skip(workspace, repo, meta), err(Debug))] - pub fn unapply<'ws>( + pub fn unapply( branch: &FullNameRef, - workspace: &'ws but_graph::Workspace, + workspace: &but_graph::Workspace, repo: &gix::Repository, meta: &mut impl RefMetadata, Options { workspace_disposition, }: Options, - ) -> anyhow::Result> { + ) -> anyhow::Result { let ws = workspace; - let mut branch_ref = try_find_validated_ref(repo, branch, "unapply")?; - let branch_commit_id = branch_ref - .as_mut() - .map(|reference| reference.peel_to_id().map(|id| id.detach())) - .transpose()?; - - if ws.has_workspace_commit_in_ancestry(repo) { - bail!("Refusing to work on workspace whose workspace commit isn't at the top"); - } + let branch_ref = try_find_validated_ref(repo, branch, "unapply")?; - let workspace_ref_name = ws.ref_name().map(ToOwned::to_owned); + crate::branch::ensure_workspace_commit_at_top(ws, repo)?; + + let workspace_ref_name = ws.ref_name_owned(); if matches!(ws.kind, but_graph::workspace::WorkspaceKind::AdHoc) && branch_ref .as_ref() @@ -244,10 +243,15 @@ pub(crate) mod function { ); } // The branch exists in Git, but does not in the workspace: Nothing to do. - return Ok(Outcome::new(Cow::Borrowed(workspace))); + return Ok(Outcome::new(None)); } - let branch_stack_was_entrypoint = branch_in_ws - .is_some_and(|(stack, _)| stack.segments.iter().any(|segment| segment.is_entrypoint)); + let branch_stack_was_entrypoint = { + let answer = ws + .segment_graph + .segment_location_by_name(branch) + .is_some_and(|(stack_idx, _)| ws.entry_marks_stack(stack_idx)); + answer + }; let workspace_tip_was_entrypoint = ws.is_entrypoint(); let Some(workspace_ref_name) = workspace_ref_name else { @@ -255,21 +259,11 @@ pub(crate) mod function { bail!("Cannot unapply a branch from an ad-hoc detached workspace"); }; let mut ws_md = meta.workspace(workspace_ref_name.as_ref())?; - if ws.kind.has_managed_ref() || ws.has_metadata() { - ws_md.reconcile_projected_stacks( - ws.stacks.iter().map(|stack| ProjectedWorkspaceStack { - id: stack.id, - branches: stack - .segments - .iter() - .filter_map(|segment| segment.ref_name().map(ToOwned::to_owned)) - .collect(), - }), - |_| StackId::generate(), - )?; - } + // The branch leaves the DECLARATION with one targeted edit โ€” nothing else in + // metadata is touched. An undeclared branch (a natural workspace) simply has + // nothing to remove; the graph edit below works either way. let branch_removed_from_ws_meta = ws_md.unapply_branch(branch); - if !branch_removed_from_ws_meta { + if !(branch_removed_from_ws_meta || ws.kind.has_managed_ref() || ws.has_metadata()) { // The branch wasn't in workspace metadata, yet it was present, so also delete its branch metadata // as it could be used to disambiguate the segment. // TODO: this will actually be observable even if it doens't work, unless it's run in a transaction, which right now it's not! @@ -282,37 +276,120 @@ pub(crate) mod function { branch = branch.shorten() ); } - return Ok(Outcome::new(Cow::Owned(workspace))); + return Ok(Outcome::new(Some(workspace))); } - // Everything past this point is stricly in non-dry-run mode and we may totally end up in intermediate states - // if something fails. - // Redo the traversal with the changed workspace metadata so code below can rely on the reconciled version. - let ws = ws.redo( - repo, - meta, - Overlay::default() - .with_dropped_references([branch.to_owned()]) - .with_workspace_metadata_override(Some(( - workspace_ref_name.to_owned(), - ws_md.clone(), - ))), - )?; - // Normal unapply first: - // - re-merge or collapse the workspace commit - // - point workspace to it - // - update metadata and workspace - let WorkspaceRefUpdateAfterUnapply { - entrypoint_id, - workspace_merge, - } = update_workspace_ref_after_unapply( - &ws, - repo, - workspace_ref_name.as_ref(), - &ws_md, - workspace_disposition, - branch_commit_id, - )?; + // THE GRAPH EDIT: unapplying a stack's TIP branch + // removes the entire stack โ€” its workspace parent goes. A mid-stack branch is pure + // de-listing: its commits stay with the stack and the graph is already right. + enum ParentEdit { + KeepAsIs, + Remove, + } + let parent_edit = if ws.is_stack_tip(branch) { + ParentEdit::Remove + } else { + ParentEdit::KeepAsIs + }; + let subject_stack_index = ws + .segment_graph + .segment_location_by_name(branch) + .map(|(stack_idx, _)| stack_idx) + .context("BUG: the subject was found in the workspace above")?; + // The tips the workspace has AFTER the edit โ€” the disposition decides on them + // like it always has (collapse when one or none remains and the disposition + // does not insist on a workspace commit). + // + // Reconciled, NOT a graph query (probed): removing the subject's stack is + // index-based over the view because stacks can share one commit (two + // stacks collapsed onto it) โ€” filtering the workspace merge's parents by commit + // value would drop a SIBLING stack and wrongly collapse the workspace. The count + // that drives keep-vs-collapse likewise needs the reconciled stack list (natural + // and at-bound stacks the graph's merge parents don't carry). + let future_tips: Vec = ws + .segment_graph + .stacks + .iter() + .enumerate() + .filter_map(|(index, stack)| { + if index == subject_stack_index && matches!(parent_edit, ParentEdit::Remove) { + None + } else { + stack.resting_commit() + } + }) + .collect(); + let keep_workspace_commit = match workspace_disposition { + WorkspaceDisposition::KeepWorkspaceCommit + | WorkspaceDisposition::PreventUnnecessaryWorkspaceReferencesKeepWorkspaceCommit => { + ws.kind.has_managed_commit() + } + WorkspaceDisposition::KeepWorkspaceReference + | WorkspaceDisposition::PreventUnnecessaryWorkspaceReferences => future_tips.len() > 1, + }; + + let (entrypoint_id, workspace_merge) = if !keep_workspace_commit { + // Collapse: the workspace ref points straight at what remains. + let new_head_id = future_tips + .first() + .copied() + .or_else(|| ws.resolved_target_commit_id()) + .or(ws.lower_bound) + .context("Cannot determine commit for empty workspace after unapply")?; + checkout_and_update_workspace_ref(repo, new_head_id, workspace_ref_name.as_ref())?; + (new_head_id, None) + } else if matches!(parent_edit, ParentEdit::KeepAsIs) { + // Mid-stack de-listing: the merge is already right. + ( + ws.tip_commit_id() + .context("BUG: a kept workspace commit implies a tip")?, + None, + ) + } else { + // One editor mutation: the workspace merge minus the subject's parent (onto + // the base when nothing remains). Selecting the parent BY REFERENCE routes + // the removal through the subject group's own carried edges, so duplicate + // parents disambiguate per ref. materialize() persists objects, safe-checkouts, + // and only then moves refs โ€” the abort-before-ref-moves discipline. + let ws_commit = ws + .tip_commit_id() + .context("BUG: a kept workspace commit implies a tip")?; + let mut editor = but_rebase::graph_rebase::Editor::create( + ws.commit_graph(), + ws.project_meta(), + meta, + repo, + )?; + let ws_pick = editor.select_commit(ws_commit)?; + let subject_ref = editor.select_reference(branch)?; + let removed = editor.detach(ws_pick, subject_ref)?; + ensure!( + !removed.is_empty(), + "BUG: the subject's workspace parent must carry at least one edge" + ); + if future_tips.is_empty() { + // An emptied workspace keeps its managed commit ON the base โ€” the + // subject's parent was its only content, so re-parent unconditionally + // (a residual parent, were one ever present, must not leave the + // emptied commit off the base). + let base = ws + .resolved_target_commit_id() + .or(ws.lower_bound) + .context("Cannot determine commit for empty workspace after unapply")?; + let base_pick = editor.select_commit(base)?; + editor.insert_edge(ws_pick, base_pick, 0)?; + } + let materialized = editor.rebase()?.materialize()?; + drop(materialized); + let new_head_id = repo + .find_reference(workspace_ref_name.as_ref())? + .peel_to_id()? + .detach(); + ( + new_head_id, + (!future_tips.is_empty()).then_some(new_head_id), + ) + }; meta.set_workspace(&ws_md)?; // Update the workspace *only* after a successful workspace commit merge. let overlay = Overlay::default() @@ -346,7 +423,9 @@ pub(crate) mod function { branch.shorten() ); } - match ref_to_checkout_after_workspace_deletion(&ws, workspace_disposition)? { + // Checkout-target selection is user-facing: it reads the pruned display stacks. + let display = ws; + match ref_to_checkout_after_workspace_deletion(&display, workspace_disposition)? { Some(ref_to_switch_to) => { // The rebuilt workspace can be discarded entirely, switching to another branch. safe_checkout_ref_to_checkout( @@ -362,7 +441,8 @@ pub(crate) mod function { repo, ref_to_switch_to.ref_name.as_ref(), workspace_ref_name.as_ref(), - ws.tip_commit_id() + display + .tip_commit_id() .context("BUG: unborn should be impossible here")?, )?; // Keep the workspace metadata or we lose the target branch. @@ -374,16 +454,16 @@ pub(crate) mod function { Some(ref_to_switch_to.ref_name.clone()), ) .with_dropped_references([branch.to_owned()]); - let ws = ws.redo(repo, meta, overlay)?; + let ws = display.redo(repo, meta, overlay)?; Ok(Outcome { - workspace: Cow::Owned(ws), + workspace: Some(ws), checked_out: Some(ref_to_switch_to.ref_name), workspace_merge: None, }) } None => Ok(Outcome { - workspace: Cow::Owned(ws), + workspace: Some(display), checked_out, workspace_merge, }), @@ -418,119 +498,13 @@ pub(crate) mod function { "BUG: workspace ref '{}' points to {actual_workspace_ref_id}, expected {expected_workspace_ref_id}", workspace_ref_name.shorten() ); - repo.edit_reference(RefEdit { - change: Change::Update { - log: LogChange { - mode: RefLog::AndReference, - force_create_reflog: false, - message: "GitButler switch to workspace during unapply-branch".into(), - }, - expected: PreviousValue::Any, - new: Target::Symbolic(workspace_ref_name.to_owned()), - }, - name: "HEAD".try_into().expect("well-formed root ref"), - deref: false, - })?; + repo.edit_reference(crate::branch::ref_edits::head_to_ref( + workspace_ref_name, + "GitButler switch to workspace during unapply-branch", + ))?; Ok(()) } - /// Update the managed workspace reference after metadata has removed the branch. - /// - /// If the remaining applied stacks still need a workspace merge commit, this rebuilds it in - /// memory first, then safely checks out the resulting tree and moves the workspace ref. - /// Workspace merge conflicts are errors and leave refs, metadata, index, and worktree - /// untouched. - /// - /// `ws` is the workspace projection after the branch was removed from workspace metadata. - /// `ws_md` is the metadata that produced that projection. `repo` is the repository whose - /// workspace ref and worktree may be updated. `workspace_ref_name` is the managed workspace - /// ref to move to the new workspace commit. `disposition` controls whether an unnecessary - /// workspace merge commit is kept or not. - fn update_workspace_ref_after_unapply( - ws: &but_graph::Workspace, - repo: &gix::Repository, - workspace_ref_name: &FullNameRef, - ws_md: &but_core::ref_metadata::Workspace, - disposition: WorkspaceDisposition, - excluded_anonymous_tip_id: Option, - ) -> anyhow::Result { - let future_workspace_seeds = future_workspace_seeds(ws_md, ws, excluded_anonymous_tip_id)?; - let remaining_tip_count = future_workspace_seeds.len(); - let keep_workspace_commit = match disposition { - WorkspaceDisposition::KeepWorkspaceCommit - | WorkspaceDisposition::PreventUnnecessaryWorkspaceReferencesKeepWorkspaceCommit => { - ws.kind.has_managed_commit() - } - WorkspaceDisposition::KeepWorkspaceReference - | WorkspaceDisposition::PreventUnnecessaryWorkspaceReferences => { - remaining_tip_count > 1 - } - }; - - if !keep_workspace_commit { - let new_head_id = - commit_to_point_workspace_ref_to_after_unapply(ws, &future_workspace_seeds)?; - checkout_and_update_workspace_ref(repo, new_head_id, workspace_ref_name)?; - return Ok(WorkspaceRefUpdateAfterUnapply { - entrypoint_id: new_head_id, - workspace_merge: None, - }); - } - - let mut in_memory_repo = repo.clone().for_tree_diffing()?.with_object_memory(); - let merge = merge_workspace_after_unapply(ws, &future_workspace_seeds, &in_memory_repo)?; - - // materialize the merged objects. - let new_head_id = merge.workspace_commit_id; - if let Some(storage) = in_memory_repo.objects.take_object_memory() { - storage.persist(repo)?; - drop(in_memory_repo); - } - checkout_and_update_workspace_ref(repo, new_head_id, workspace_ref_name)?; - Ok(WorkspaceRefUpdateAfterUnapply { - entrypoint_id: new_head_id, - workspace_merge: merge.workspace_merge, - }) - } - - /// Result of updating a managed workspace ref while keeping the workspace metadata around. - struct WorkspaceRefUpdateAfterUnapply { - /// Commit to use as the entrypoint when rebuilding the workspace projection. - entrypoint_id: gix::ObjectId, - /// Merge attempt, present when rebuilding or trying to rebuild the workspace commit. - workspace_merge: Option, - } - - struct WorkspaceMergeAfterUnapply { - /// The commit to materialize as the new workspace ref target. - workspace_commit_id: gix::ObjectId, - /// The merge attempt callers should receive, if it represents a real workspace merge. - /// - /// This is `None` for the legacy empty-workspace keep-merge case, where the merge outcome - /// is only an implementation detail used to create a managed no-op workspace commit. - workspace_merge: Option, - } - - fn future_workspace_seeds( - ws_md: &but_core::ref_metadata::Workspace, - ws: &but_graph::Workspace, - excluded_anonymous_tip_id: Option, - ) -> anyhow::Result> { - let crate::commit::merge::ResolvedTips { - tips, - missing_stacks, - } = WorkspaceCommit::tips_from_metadata( - ws_md.stacks.iter(), - anon_stacks_to_preserve(ws, excluded_anonymous_tip_id), - ws, - ); - ensure!( - missing_stacks.is_empty(), - "Somehow some of the workspace stacks weren't part of the graph: {missing_stacks:#?}" - ); - Ok(tips) - } - /// Unapply the managed workspace reference itself. /// /// - Run before normal branch removal because the workspace ref is a container, not a stack segment. @@ -546,7 +520,7 @@ pub(crate) mod function { meta: &mut impl RefMetadata, workspace_ref_name: &FullNameRef, disposition: WorkspaceDisposition, - ) -> anyhow::Result> { + ) -> anyhow::Result { if !disposition.may_switch_away_from_workspace() { bail!( "Cannot unapply workspace reference '{}' without switching away from it", @@ -554,6 +528,8 @@ pub(crate) mod function { ); } + // Checkout-target selection is user-facing: it reads the pruned display stacks + // (Phase C's one intended exception). let ref_to_checkout = ref_to_checkout_after_workspace_unapply(ws)?; safe_checkout_ref_to_checkout( repo, @@ -587,90 +563,12 @@ pub(crate) mod function { ); let ws = ws.redo(repo, meta, overlay)?; Ok(Outcome { - workspace: Cow::Owned(ws), + workspace: Some(ws), checked_out: Some(ref_to_checkout.ref_name), workspace_merge: None, }) } - /// Rebuild the managed workspace commit after `branch` has been removed from metadata. - /// - /// The metadata is resolved to tips first so all rebuilds use the lower-level tip merge API. - /// If metadata and anonymous workspace inputs resolve to no tips, the legacy keep-merge path - /// still needs a managed commit, so we synthesize a single unnamed tip at the post-unapply - /// base/checkout commit. - fn merge_workspace_after_unapply( - ws: &but_graph::Workspace, - future_workspace_seeds: &[crate::commit::merge::Seed], - repo: &gix::Repository, - ) -> anyhow::Result { - let mut tips = future_workspace_seeds.to_vec(); - let report_workspace_merge = !tips.is_empty(); - if tips.is_empty() { - tips.push(base_tip_after_unapply(ws, future_workspace_seeds)?); - } - let outcome = WorkspaceCommit::from_new_merge_with_tips(tips, ws, repo, None)?; - ensure_workspace_merge_has_no_conflicts(&outcome)?; - let workspace_commit_id = outcome.workspace_commit_id; - Ok(WorkspaceMergeAfterUnapply { - workspace_commit_id, - workspace_merge: report_workspace_merge.then_some(outcome), - }) - } - - fn ensure_workspace_merge_has_no_conflicts( - outcome: &crate::commit::merge::Outcome, - ) -> anyhow::Result<()> { - ensure!( - !outcome.has_conflicts(), - "Failed to unapply branch due to conflicts while rebuilding the workspace merge commit: {}", - describe_conflicting_stacks(&outcome.conflicting_stacks) - ); - Ok(()) - } - - fn describe_conflicting_stacks( - conflicting_stacks: &[crate::commit::merge::ConflictingStack], - ) -> String { - let ref_names = conflicting_stacks - .iter() - .filter_map(|stack| stack.ref_name.as_ref()) - .map(|ref_name| ref_name.shorten().to_string()) - .collect::>(); - if ref_names.is_empty() { - format!("{} stack(s)", conflicting_stacks.len()) - } else { - ref_names.join(", ") - } - } - - /// Convert the commit that should remain checked out after unapply into a synthetic merge tip. - fn base_tip_after_unapply( - ws: &but_graph::Workspace, - future_workspace_seeds: &[crate::commit::merge::Seed], - ) -> anyhow::Result { - let commit_id = commit_to_point_workspace_ref_to_after_unapply(ws, future_workspace_seeds)?; - Ok(crate::commit::merge::Seed { - name: None, - commit_id, - }) - } - - /// Return anonymous stacks that should be kept while rebuilding the workspace merge commit. - /// - /// Reprojecting after metadata changes can leave old workspace-commit parents visible as - /// anonymous stacks. If such a stack still has a metadata [stack id](but_core::ref_metadata::StackId), - /// it is a projected remnant of the old workspace commit rather than independent anonymous work, - /// and feeding it back into the merge would preserve the removed stack. - fn anon_stacks_to_preserve<'a>( - ws: &'a but_graph::Workspace, - excluded_tip_id: Option, - ) -> impl Iterator + 'a { - anon_stacks(&ws.stacks) - .filter(move |(idx, _)| ws.stacks.get(*idx).is_none_or(|stack| stack.id.is_none())) - .filter(move |(_, tip)| excluded_tip_id.is_none_or(|id| tip.commit_id != id)) - } - /// Local tracking branch of target ref or the most recent named stack tip. fn ref_to_checkout_after_workspace_unapply( ws: &but_graph::Workspace, @@ -690,7 +588,7 @@ pub(crate) mod function { fn most_recent_named_stack(ws: &but_graph::Workspace) -> anyhow::Result> { let cg = ws.commit_graph(); let mut selected = None; - for stack in &ws.stacks { + for stack in ws.display_stacks()? { let Some((ref_info, anchor)) = stack.segments.first().and_then(|s| { s.ref_info .as_ref() @@ -747,23 +645,8 @@ pub(crate) mod function { ref_to_checkout.ref_name.shorten() ); } - safe_checkout(repo, ref_to_checkout.commit_id, options) - } - - /// Return the commit the workspace ref should point to when no workspace merge commit remains. - /// - /// A single remaining future tip is preferred. Otherwise, an empty workspace falls back to the - /// resolved target or the workspace lower bound. - fn commit_to_point_workspace_ref_to_after_unapply( - ws: &but_graph::Workspace, - future_workspace_seeds: &[crate::commit::merge::Seed], - ) -> anyhow::Result { - if let Some(tip) = future_workspace_seeds.first() { - return Ok(tip.commit_id); - } - ws.resolved_target_commit_id() - .or(ws.lower_bound) - .context("Cannot determine commit for empty workspace after unapply") + // The ref-aware conflict check above already covered what `safe_checkout` would re-check. + but_core::worktree::safe_checkout_from_head(ref_to_checkout.commit_id, repo, options) } /// Safely update the worktree and move the managed workspace ref to `new_head_id`. @@ -784,19 +667,11 @@ pub(crate) mod function { ..Default::default() }, )?; - repo.edit_reference(RefEdit { - change: Change::Update { - log: LogChange { - mode: RefLog::AndReference, - force_create_reflog: false, - message: "GitButler update workspace during unapply-branch".into(), - }, - expected: PreviousValue::Any, - new: Target::Object(new_head_id), - }, - name: workspace_ref_name.to_owned(), - deref: false, - })?; + repo.edit_reference(crate::branch::ref_edits::ref_to_commit( + workspace_ref_name.to_owned(), + new_head_id, + "GitButler update workspace during unapply-branch", + ))?; Ok(()) } @@ -817,7 +692,7 @@ pub(crate) mod function { return Ok(None); } - match ws.stacks.first() { + match ws.display_stacks()?.first() { None => { if let Some(fallback) = local_tracking_branch_of_target(ws)? { return Ok(Some(fallback)); @@ -827,7 +702,7 @@ pub(crate) mod function { "keeping workspace reference after unapply because no non-stack checkout fallback is available" ); } - Some(first_stack) if ws.stacks.len() == 1 => { + Some(first_stack) if ws.display_stacks()?.len() == 1 => { if let Some(ref_to_checkout) = stack_to_checkout(ws, first_stack)? { return Ok(Some(ref_to_checkout)); } @@ -892,8 +767,12 @@ pub(crate) mod function { ) -> anyhow::Result { Ok(RefToCheckout { ref_name: ref_info.ref_name.clone(), + // Checkout-target selection is USER-FACING: resolve the resting commit over the + // pruned DISPLAY (what the user sees), deliberately โ€” this ref was itself picked + // from the display. Structural OPERATION decisions resolve on the segment graph + // instead; the checkout path is the one intended exception. commit_id: ws - .branch_resting_commit_id(ref_info.ref_name.as_ref()) + .branch_resting_commit_id_in_display(ref_info.ref_name.as_ref()) .or(ref_info.commit_id) .with_context(|| { format!( @@ -927,20 +806,10 @@ pub(crate) mod function { name: workspace_ref_name.to_owned(), deref: false, }, - RefEdit { - change: Change::Update { - log: LogChange { - mode: RefLog::AndReference, - force_create_reflog: false, - message: "GitButler switch away from workspace during unapply-branch" - .into(), - }, - expected: PreviousValue::Any, - new: Target::Symbolic(target_ref.to_owned()), - }, - name: "HEAD".try_into().expect("well-formed root ref"), - deref: false, - }, + crate::branch::ref_edits::head_to_ref( + target_ref, + "GitButler switch away from workspace during unapply-branch", + ), ])?; Ok(()) } diff --git a/crates/but-workspace/src/branch_details.rs b/crates/but-workspace/src/branch_details.rs index 05128f47901..5dc0eec10f7 100644 --- a/crates/but-workspace/src/branch_details.rs +++ b/crates/but-workspace/src/branch_details.rs @@ -33,14 +33,13 @@ pub fn branch_details( meta: &impl RefMetadata, project_meta: &ProjectMeta, ) -> anyhow::Result { - workspace_data_of_default_workspace_branch(meta)?.context( - "TODO: cannot run in non-workspace mode yet.\ - It would need a way to deal with limiting the commit traversal", - )?; + // Non-workspace mode would need a way to limit the commit traversal. + workspace_data_of_default_workspace_branch(meta)? + .context("Branch details are only available inside a GitButler workspace")?; let integration_branch_name = project_meta .target_ref .clone() - .context("TODO: a target to integrate with is currently needed for a workspace commit")?; + .context("Branch details require a configured target branch to integrate with")?; let mut integration_branch = repo .find_reference(&integration_branch_name) .context("The branch to integrate with must be present")?; diff --git a/crates/but-workspace/src/commit/discard_commit.rs b/crates/but-workspace/src/commit/discard_commit.rs index 8e6fb83dbdd..3a3347bd2ee 100644 --- a/crates/but-workspace/src/commit/discard_commit.rs +++ b/crates/but-workspace/src/commit/discard_commit.rs @@ -2,10 +2,7 @@ use anyhow::bail; use but_core::RefMetadata; -use but_rebase::graph_rebase::{ - Editor, Step, SuccessfulRebase, - selector::{SelectorSet, StepRange}, -}; +use but_rebase::graph_rebase::{Editor, SuccessfulRebase}; /// Discard one or more commits in a single rebase operation. /// @@ -24,14 +21,7 @@ pub fn discard_commits<'meta, M: RefMetadata>( } count += 1; let (selector, _commit) = editor.find_selectable_commit(commit_id)?; - - let range = StepRange { - child: selector, - parent: selector, - }; - - editor.disconnect_range_from(range, SelectorSet::All, SelectorSet::All, false)?; - editor.replace(selector, Step::None)?; + editor.remove_commit(selector)?; } if count == 0 { diff --git a/crates/but-workspace/src/commit/mod.rs b/crates/but-workspace/src/commit/mod.rs index c365b94e500..e17bbcdc5ac 100644 --- a/crates/but-workspace/src/commit/mod.rs +++ b/crates/but-workspace/src/commit/mod.rs @@ -1,5 +1,5 @@ use anyhow::Context as _; -use bstr::{BString, ByteSlice}; +use bstr::ByteSlice; use but_core::DiffSpec; use but_core::ref_metadata::MaybeDebug; @@ -72,19 +72,20 @@ pub use squash_commits::{SquashCommitsOutcome, squash_commits}; pub struct Stack { /// The tip of the top-most branch, i.e., the most recent commit that would become the parent of new commits of the topmost stack branch. pub tip: gix::ObjectId, - /// The short name of the stack, which is the name of the top-most branch, - /// like `main` or `feature/branch` or `origin/tracking-some-PR` or something entirely made up. - pub name: Option, + /// The tip branch's FULL ref name โ€” shown (shortened) in the workspace commit + /// message, and recorded into the commit's parent binding header so the + /// projection reads intent instead of inferring. + pub ref_name: Option, } impl std::fmt::Debug for Stack { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let Stack { tip, name } = self; + let Stack { tip, ref_name } = self; write!( f, "Stack {{ tip: {tip}, name: {name:?} }}", tip = tip.to_hex_with_len(7), - name = MaybeDebug(name) + name = MaybeDebug(&ref_name.as_ref().map(|rn| rn.shorten().to_owned())) ) } } @@ -181,36 +182,42 @@ pub mod merge { stacks: impl IntoIterator, anon_stacks: impl IntoIterator, workspace: &but_graph::Workspace, - ) -> ResolvedTips { + ) -> anyhow::Result { let mut missing_stacks = Vec::new(); - let mut tips_with_metadata_numbers: Vec<_> = stacks + // `None` entries (outside/missing stacks) still occupy their slot: the anonymous-tip + // insertion below is positional over this list. + let mut tips_with_metadata_numbers: Vec> = Vec::new(); + for (top_segment, relation) in stacks .into_iter() .filter_map(|s| s.branches.first().map(|b| (b, s.workspacecommit_relation))) - .map(|(top_segment, relation)| { - match relation { - WorkspaceCommitRelation::Merged => {} - WorkspaceCommitRelation::MergeFrom { .. } => { - // These need to be part of the parents list, but shouldn't be merged. - // If the caller wants to retry them, they can be passed here as "Merged". - todo!( - "this is a placeholder for where we will have to start handling this UnmergedTree" - ) + { + let seed = match relation { + WorkspaceCommitRelation::Merged => { + let stack_tip_name = top_segment.ref_name.as_ref(); + match workspace.commit_id_by_ref_name(stack_tip_name) { + None => { + missing_stacks.push(top_segment.ref_name.to_owned()); + None + } + Some(commit_id) => Some(Seed { + name: Some(stack_tip_name.to_owned()), + commit_id, + }), } - WorkspaceCommitRelation::Outside => return None, } - let stack_tip_name = top_segment.ref_name.as_ref(); - match workspace.commit_id_by_ref_name(stack_tip_name) { - None => { - missing_stacks.push(top_segment.ref_name.to_owned()); - None - } - Some(commit_id) => Some(Seed { - name: Some(stack_tip_name.to_owned()), - commit_id, - }), + WorkspaceCommitRelation::MergeFrom { .. } => { + // These would join the parents list without being merged; callers that + // want them re-merged pass them as `Merged`. Nothing constructs this + // relation yet, so error instead of building a wrong merge. + anyhow::bail!( + "Cannot build a workspace merge for '{}': its unmerged-tree (MergeFrom) relation is not supported yet", + top_segment.ref_name + ); } - }) - .collect(); + WorkspaceCommitRelation::Outside => None, + }; + tips_with_metadata_numbers.push(seed); + } let named_tips = tips_with_metadata_numbers .iter() .flatten() @@ -226,40 +233,32 @@ pub mod merge { tips_with_metadata_numbers .insert(idx.min(tips_with_metadata_numbers.len()), Some(anon_tip)); } - ResolvedTips { + Ok(ResolvedTips { tips: tips_with_metadata_numbers.into_iter().flatten().collect(), missing_stacks, - } + }) } /// like [`Self::from_new_merge_with_metadata`], but supports tips, which makes it possible to re-merge anything /// even if the tip is unnamed. /// Note that [`missing_stacks`](Outcome::missing_stacks) is never set. + /// + /// ### Algorithm + /// + /// Fold the tips left to right into one tree, enforcing the lowest merge-base by carrying + /// the previous iteration's base forward. A conflicting tip is skipped; a conflicting HERO + /// tip instead skips its nearest merged predecessor and restarts, since the hero must land. + /// Once the hero merges after skips, [`schedule_merge_trials_after_hero`] re-arms the + /// not-yet-proven skips as trials, the fold restarts once more, and each trial is settled + /// by [`resolve_merge_trial`] โ€” so only tips that truly conflict with the hero stay out. + /// [`conclude_tips_merge`] partitions the verdicts and writes the merge commit. pub fn from_new_merge_with_tips( tips: impl IntoIterator, workspace: &but_graph::Workspace, repo: &gix::Repository, hero_stack: Option<&gix::refs::FullNameRef>, ) -> anyhow::Result { - #[derive(Debug)] - enum Instruction { - Merge, - MergeTrial { - hero_commit_id: gix::ObjectId, - hero_tree_id: gix::ObjectId, - }, - Skip, - CertainConflict, - } use Instruction as I; - impl Instruction { - fn should_skip(&self) -> bool { - match self { - I::Merge | I::MergeTrial { .. } => false, - I::Skip | I::CertainConflict => true, - } - } - } let mut tips: Vec<(Instruction, Seed)> = tips.into_iter().map(|t| (I::Merge, t)).collect(); @@ -277,6 +276,7 @@ pub mod merge { Seed { name: ref_name, commit_id, + .. }, ) = &mut tips[tip_idx]; let this_commit_id = *commit_id; @@ -329,44 +329,11 @@ pub mod merge { continue 'tips_loop; } } else if is_hero { - // Look back and see if there is any skipped stacks. If so, we now merged the hero branch successfully, - // - // This means that skipping some worked. Now we want to try to re-enable previously disabled ones to learn if they - // were really at fault. Imagine `G1 X X X X X H` with H being hero and G1 being the good ones. - // It's notable how multiple branches of these X can be good, but some in the middle can also be bad - imagine - // one file being wrong in one, and another in another stack, so two stacks are causing conflicts while some - // in between are not causing conflicts. - // With this, we might find that it's actually `G1 X G2 G3 X G4 H`, and we don't unnecessarily unapply unrelated branches. - // However, we only know that the first X is definitely a conflict, and all others we have to test one after another - // by test-merging H right after the X under test. - - // First, mark the first X as conflict as we know it for sure. - let mut saw_first_certain_conflict = false; - let mut has_merge_trials = false; - for (mode, _) in &mut tips[..tip_idx] { - match mode { - I::Merge => continue, - I::MergeTrial { .. } => { - bail!( - "BUG: found a merge-trial, even though trial should be concluded by now" - ) - } - I::CertainConflict => saw_first_certain_conflict = true, - I::Skip => { - if saw_first_certain_conflict { - *mode = I::MergeTrial { - hero_commit_id: this_commit_id, - hero_tree_id: this_tree_id, - }; - has_merge_trials = true; - } else { - *mode = I::CertainConflict; - saw_first_certain_conflict = true; - } - } - } - } - + let has_merge_trials = schedule_merge_trials_after_hero( + &mut tips[..tip_idx], + this_commit_id, + this_tree_id, + )?; if has_merge_trials { if ran_merge_trials_loop_safety { bail!( @@ -382,28 +349,17 @@ pub mod merge { hero_tree_id, } = *mode { - // This stack merged cleanly, and now we have to merge the hero into that result to see if it works. - // This tells us if this is stack merges cleanly or causes a real conflict in conjunction with hero. - let base_tree_id = compute_merge_base( + *mode = resolve_merge_trial( workspace, repo, + &mut merge, base_commit_id, hero_commit_id, - )? - .0; - let merge = repo.merge_trees( - base_tree_id, - merge.tree.write()?, hero_tree_id, labels_uninteresting_as_no_conflict_allowed, - merge_options.clone(), + &merge_options, + conflict_kind, )?; - let trial_outcome = if merge.has_unresolved_conflicts(conflict_kind) { - I::CertainConflict - } else { - I::Merge - }; - *mode = trial_outcome; if matches!(mode, I::CertainConflict) { // Now that we know it's actually a conflict, do not retain more state so // the conflicting one isn't recorded in the merge. @@ -416,58 +372,7 @@ pub mod merge { previous_tip = Some((this_tree_id, this_commit_id)); } - let (stacks, conflicting_stacks) = tips.iter().fold( - (Vec::new(), Vec::new()), - |(mut stacks, mut conflicting_stacks), - ( - mode, - Seed { - name: ref_name, - commit_id, - .. - }, - )| { - if mode.should_skip() { - conflicting_stacks.push(ConflictingStack { - tip: *commit_id, - ref_name: ref_name.clone(), - }); - } else { - stacks.push(Stack { - tip: *commit_id, - name: ref_name.as_ref().map(|rn| rn.shorten().to_owned()), - }); - } - (stacks, conflicting_stacks) - }, - ); - - if stacks.is_empty() { - bail!( - "BUG: Cannot merge nothing, no tips ended up in the graph: `conflicting_stacks` = {conflicting_stacks:?}, `tips` = : {tips:?}" - ) - } - - let merge_tree_id = merge_tree_id - .or({ - // Just one stack? - previous_tip.map(|t| t.0) - }) - .context("having stacks means the loop ran once")?; - - // Finally, create the merge-commit itself. - let mut ws_commit = - Self::new_from_stacks(stacks.iter().cloned(), repo.object_hash()); - ws_commit.tree = merge_tree_id; - Self::fixup_times(&mut ws_commit, repo); - - let workspace_commit_id = repo.write_object(&ws_commit)?.detach(); - return Ok(Outcome { - workspace_commit_id, - stacks, - missing_stacks: vec![], /* this is never set here as all tips are already resolved */ - conflicting_stacks, - }); + return conclude_tips_merge(&tips, merge_tree_id, previous_tip, repo); } } @@ -506,13 +411,170 @@ pub mod merge { let ResolvedTips { tips, missing_stacks, - } = Self::tips_from_metadata(stacks, anon_stacks, workspace); + } = Self::tips_from_metadata(stacks, anon_stacks, workspace)?; let mut out = Self::from_new_merge_with_tips(tips, workspace, repo, hero_stack)?; out.missing_stacks = missing_stacks; Ok(out) } } + /// Per-tip verdict while folding the workspace merge in + /// [`WorkspaceCommit::from_new_merge_with_tips`]. + #[derive(Debug)] + enum Instruction { + /// Merge this tip into the fold. + Merge, + /// A previously skipped tip re-armed for testing: merge it, then test-merge the hero on + /// top to learn whether it truly conflicts with the hero or was skipped by accident. + MergeTrial { + /// The hero's commit, the trial's other side. + hero_commit_id: gix::ObjectId, + /// The hero's tree, the trial's other side. + hero_tree_id: gix::ObjectId, + }, + /// Leave this tip out of the fold (it conflicted, or is under suspicion). + Skip, + /// Proven to conflict โ€” permanently out. + CertainConflict, + } + + impl Instruction { + fn should_skip(&self) -> bool { + match self { + Instruction::Merge | Instruction::MergeTrial { .. } => false, + Instruction::Skip | Instruction::CertainConflict => true, + } + } + } + + /// After the hero merged despite earlier skips, decide which skips were real conflicts: + /// the FIRST skip is a certain conflict (the hero conflicted right after it), every later + /// one becomes a [`Instruction::MergeTrial`] to be proven individually โ€” imagine + /// `G1 X X X X X H`: only some of the X may truly conflict with `H`, and each is tested by + /// merging `H` right after it. Returns whether any trials were scheduled (the fold must + /// restart to run them). + fn schedule_merge_trials_after_hero( + tips_before_hero: &mut [(Instruction, Seed)], + hero_commit_id: gix::ObjectId, + hero_tree_id: gix::ObjectId, + ) -> anyhow::Result { + let mut saw_first_certain_conflict = false; + let mut has_merge_trials = false; + for (mode, _) in tips_before_hero { + match mode { + Instruction::Merge => continue, + Instruction::MergeTrial { .. } => { + bail!("BUG: found a merge-trial, even though trial should be concluded by now") + } + Instruction::CertainConflict => saw_first_certain_conflict = true, + Instruction::Skip => { + if saw_first_certain_conflict { + *mode = Instruction::MergeTrial { + hero_commit_id, + hero_tree_id, + }; + has_merge_trials = true; + } else { + *mode = Instruction::CertainConflict; + saw_first_certain_conflict = true; + } + } + } + } + Ok(has_merge_trials) + } + + /// Settle one [`Instruction::MergeTrial`]: the tip under trial merged cleanly into the fold + /// (`merge`), so test-merge the hero on top of that result โ€” a clean merge acquits the tip + /// ([`Instruction::Merge`]), a conflict convicts it ([`Instruction::CertainConflict`]). + #[expect(clippy::too_many_arguments)] + fn resolve_merge_trial( + workspace: &but_graph::Workspace, + repo: &gix::Repository, + merge: &mut gix::merge::tree::Outcome<'_>, + base_commit_id: gix::ObjectId, + hero_commit_id: gix::ObjectId, + hero_tree_id: gix::ObjectId, + labels: gix::merge::blob::builtin_driver::text::Labels<'_>, + merge_options: &gix::merge::tree::Options, + conflict_kind: gix::merge::tree::TreatAsUnresolved, + ) -> anyhow::Result { + let base_tree_id = compute_merge_base(workspace, repo, base_commit_id, hero_commit_id)?.0; + let trial = repo.merge_trees( + base_tree_id, + merge.tree.write()?, + hero_tree_id, + labels, + merge_options.clone(), + )?; + Ok(if trial.has_unresolved_conflicts(conflict_kind) { + Instruction::CertainConflict + } else { + Instruction::Merge + }) + } + + /// Conclude the fold: partition the tips by verdict into merged stacks and conflicting + /// stacks, then write the workspace merge commit over the folded tree. + fn conclude_tips_merge( + tips: &[(Instruction, Seed)], + merge_tree_id: Option, + previous_tip: Option<(gix::ObjectId, gix::ObjectId)>, + repo: &gix::Repository, + ) -> anyhow::Result { + let (stacks, conflicting_stacks) = tips.iter().fold( + (Vec::new(), Vec::new()), + |(mut stacks, mut conflicting_stacks), + ( + mode, + Seed { + name: ref_name, + commit_id, + }, + )| { + if mode.should_skip() { + conflicting_stacks.push(ConflictingStack { + tip: *commit_id, + ref_name: ref_name.clone(), + }); + } else { + stacks.push(Stack { + tip: *commit_id, + ref_name: ref_name.clone(), + }); + } + (stacks, conflicting_stacks) + }, + ); + + if stacks.is_empty() { + bail!( + "BUG: Cannot merge nothing, no tips ended up in the graph: `conflicting_stacks` = {conflicting_stacks:?}, `tips` = : {tips:?}" + ) + } + + let merge_tree_id = merge_tree_id + .or({ + // Just one stack? + previous_tip.map(|t| t.0) + }) + .context("having stacks means the loop ran once")?; + + // Finally, create the merge-commit itself. + let mut ws_commit = + WorkspaceCommit::new_from_stacks(stacks.iter().cloned(), repo.object_hash()); + ws_commit.tree = merge_tree_id; + WorkspaceCommit::fixup_times(&mut ws_commit, repo); + + let workspace_commit_id = repo.write_object(&ws_commit)?.detach(); + Ok(Outcome { + workspace_commit_id, + stacks, + missing_stacks: vec![], /* this is never set here as all tips are already resolved */ + conflicting_stacks, + }) + } + fn compute_merge_base( workspace: &but_graph::Workspace, repo: &gix::Repository, @@ -562,18 +624,19 @@ impl<'repo> WorkspaceCommit<'repo> { tree: gix::ObjectId, ) -> anyhow::Result { let stacks: Vec<_> = workspace + .segment_graph .stacks .iter() .map(|s| { let name = s.ref_name().map(|rn| rn.shorten().to_owned()); let s = Stack { - tip: s.tip_skip_empty().or(s.base()).with_context(|| { + ref_name: s.ref_name().map(ToOwned::to_owned), + tip: s.resting_commit().with_context(|| { format!( "Could not find any commit to serve as tip for stack {id:?} with name {name:?}", id = s.id ) })?, - name, }; anyhow::Ok(s) }) @@ -639,7 +702,7 @@ impl<'repo> WorkspaceCommit<'repo> { if !stacks.is_empty() { message.push_str("Here are the branches that are currently applied:\n"); for branch in &stacks { - if let Some(name) = &branch.name { + if let Some(name) = branch.ref_name.as_ref().map(|rn| rn.shorten()) { message.push_str(" - "); message.push_str(name.to_str_lossy().as_ref()); message.push('\n'); @@ -655,6 +718,22 @@ impl<'repo> WorkspaceCommit<'repo> { .push_str("https://docs.gitbutler.com/features/branch-management/integration-branch\n"); let author = commit_signature(commit_time("GIT_COMMITTER_DATE")); + // Record the parentโ†”stack binding when any stack is declared โ€” the projection + // reads it instead of inferring the match. Unknowing callers (all ids `None`) + // stamp nothing, leaving inference in charge. + let extra_headers = if stacks.iter().any(|s| s.ref_name.is_some()) { + vec![( + but_core::commit::HEADERS_WORKSPACE_PARENTS_FIELD.into(), + but_core::commit::encode_workspace_parents( + &stacks + .iter() + .map(|s| s.ref_name.clone()) + .collect::>(), + ), + )] + } else { + vec![] + }; gix::objs::Commit { tree: gix::ObjectId::empty_tree(object_hash), parents: stacks.iter().map(|s| s.tip).collect(), @@ -662,7 +741,7 @@ impl<'repo> WorkspaceCommit<'repo> { author, encoding: Some("UTF-8".into()), message: message.into(), - extra_headers: vec![], + extra_headers, } } } diff --git a/crates/but-workspace/src/commit/move_commit.rs b/crates/but-workspace/src/commit/move_commit.rs index 6db4e6a1f5f..ffcea778b9e 100644 --- a/crates/but-workspace/src/commit/move_commit.rs +++ b/crates/but-workspace/src/commit/move_commit.rs @@ -4,7 +4,7 @@ use anyhow::bail; use but_core::RefMetadata; use but_rebase::graph_rebase::{ Editor, LookupStep as _, SuccessfulRebase, ToCommitSelector, ToSelector, - mutate::InsertSide, + mutate::{InsertSide, Reconnect}, selector::{RelativeTo, SelectorSet, StepRange}, }; @@ -109,7 +109,7 @@ pub fn move_commit_no_rebase<'meta, M: RefMetadata>( commit_range.clone(), SelectorSet::All, parent_to_disconnect, - false, + Reconnect::Heal, )?; // Step 3: Insert diff --git a/crates/but-workspace/src/commit/squash_commits.rs b/crates/but-workspace/src/commit/squash_commits.rs index 98acdfa60b3..01e38d03f2e 100644 --- a/crates/but-workspace/src/commit/squash_commits.rs +++ b/crates/but-workspace/src/commit/squash_commits.rs @@ -2,13 +2,9 @@ use anyhow::{Result, bail}; use but_core::{RefMetadata, RepositoryExt}; -use but_rebase::{ - commit::DateMode, - graph_rebase::{ - Editor, LookupStep as _, Selector, Step, SuccessfulRebase, ToCommitSelector, - merge_commit_changes::MergeCommitChangesOutcome, - selector::{SelectorSet, StepRange}, - }, +use but_rebase::graph_rebase::{ + Editor, LookupStep as _, Selector, Step, SuccessfulRebase, ToCommitSelector, + merge_commit_changes::MergeCommitChangesOutcome, }; /// The result of a squash_commits operation. @@ -59,14 +55,12 @@ fn construct_new_squashed_commit<'meta, M: RefMetadata>( let (target_selector, target_commit) = editor.find_selectable_commit(target_commit_id)?; let target_parent_ids = parent_commit_ids(&editor, target_selector)?; - let new_commit = { - let mut squashed_commit = target_commit.clone(); - squashed_commit.inner.parents = target_parent_ids.into(); - squashed_commit.tree = squashed_tree.tree_id; - squashed_commit.message = combined_message.into(); - editor.new_commit(squashed_commit, DateMode::CommitterUpdateAuthorKeep)? - }; - + let new_commit = editor.new_squashed_commit( + target_commit.clone(), + target_parent_ids, + squashed_tree, + combined_message, + )?; editor.replace(target_selector, Step::new_pick(new_commit))?; Ok((editor, target_selector)) @@ -199,13 +193,7 @@ pub fn squash_commits<'meta, M: RefMetadata, S: ToCommitSelector, T: ToCommitSel let mut editor = editor; for commit_selector in subject_selectors { - let range = StepRange { - child: commit_selector, - parent: commit_selector, - }; - editor.disconnect_range_from(range, SelectorSet::All, SelectorSet::All, false)?; - let (selector, _) = editor.find_selectable_commit(commit_selector)?; - editor.replace(selector, Step::None)?; + editor.remove_commit(commit_selector)?; } let (editor, new_target_selector) = construct_new_squashed_commit( diff --git a/crates/but-workspace/src/graph_manipulation.rs b/crates/but-workspace/src/graph_manipulation.rs index 4b6b756d3e2..9a90776251c 100644 --- a/crates/but-workspace/src/graph_manipulation.rs +++ b/crates/but-workspace/src/graph_manipulation.rs @@ -2,9 +2,10 @@ use anyhow::{Context, Result, bail}; use but_core::RefMetadata; -use but_graph::workspace::{Stack, StackSegment}; +use but_graph::workspace::{Segment, SegmentStack}; use but_rebase::graph_rebase::{ Editor, LookupStep, Selector, Step, ToSelector, + mutate::Reconnect, selector::{SelectorSet, SomeSelectors, StepRange}, }; use std::collections::HashSet; @@ -25,17 +26,15 @@ pub struct DisconnectParameters { /// as well as the right segment range to move. pub fn get_disconnect_parameters<'meta, M: RefMetadata>( editor: &Editor<'meta, M>, - source_stack: &Stack, - subject_segment: &StackSegment, + source_stack: &SegmentStack, + subject_segment: &Segment, workspace_head: Option, ) -> anyhow::Result { let index_of_segment = source_stack .segments .iter() .position(|segment| { - segment.ref_name() == subject_segment.ref_name() - && segment.commits.first().map(|c| c.id) - == subject_segment.commits.first().map(|c| c.id) + segment.ref_name == subject_segment.ref_name && segment.tip() == subject_segment.tip() }) .context("BUG: Unable to find subject segment on source stack.")?; @@ -46,8 +45,8 @@ pub fn get_disconnect_parameters<'meta, M: RefMetadata>( .select_reference(subject_segment_ref_name) .context("Failed to find subject reference in graph.")?; let delimiter_parent = match subject_segment.commits.last() { - Some(last_commit) => editor - .select_commit(last_commit.id) + Some(bottom) => editor + .select_commit(*bottom) .context("Failed to find last commit in subject segment in graph.")?, None => { // Subject segment is empty, move only the reference @@ -111,8 +110,8 @@ pub fn get_disconnect_parameters<'meta, M: RefMetadata>( // the reference from it. // Otherwise, disconnect the last commit on the segment. let child_selector = match child_segment.commits.last() { - Some(last_commit) => editor - .select_commit(last_commit.id) + Some(bottom) => editor + .select_commit(*bottom) .context("Failed to find last commit of child segment in graph."), None => { // The segment on top of the subject segment is empty. Select the reference. @@ -195,7 +194,7 @@ pub(crate) fn disconnect_selector_from_all_parents( }, SelectorSet::None, SelectorSet::All, - true, + Reconnect::Skip, )?; Ok(()) diff --git a/crates/but-workspace/src/legacy/push.rs b/crates/but-workspace/src/legacy/push.rs index b068cfa5e83..f6845e1edb6 100644 --- a/crates/but-workspace/src/legacy/push.rs +++ b/crates/but-workspace/src/legacy/push.rs @@ -90,7 +90,7 @@ pub fn workspace_branch_and_ancestors_push( continue; } - let Some(local_sha) = ws.branch_resting_commit_id(ref_name) else { + let Some(local_sha) = ws.branch_resting_commit_id_in_display(ref_name) else { continue; }; diff --git a/crates/but-workspace/src/legacy/ui.rs b/crates/but-workspace/src/legacy/ui.rs index 8d66ca003cc..74a132e4ac7 100644 --- a/crates/but-workspace/src/legacy/ui.rs +++ b/crates/but-workspace/src/legacy/ui.rs @@ -105,15 +105,6 @@ pub struct StackEntryNoOpt { #[cfg(feature = "export-schema")] but_schemars::register_sdk_type!(StackEntryNoOpt); -impl From for crate::commit::Stack { - fn from(value: StackEntryNoOpt) -> Self { - crate::commit::Stack { - tip: value.tip, - name: value.name().map(ToOwned::to_owned), - } - } -} - impl StackEntry { /// The name of the stack, which is the name of the top-most branch. pub fn name(&self) -> Option<&BStr> { diff --git a/crates/but-workspace/src/ref_info.rs b/crates/but-workspace/src/ref_info.rs index 699576e585e..7e8d2b94089 100644 --- a/crates/but-workspace/src/ref_info.rs +++ b/crates/but-workspace/src/ref_info.rs @@ -520,7 +520,7 @@ pub fn graph_to_ref_info( let (kind, stacks, target_ref, metadata) = ( &workspace.kind, - &workspace.stacks, + workspace.display_stacks()?, &workspace.target_ref, &workspace.metadata, ); diff --git a/crates/but-workspace/src/ui/diff.rs b/crates/but-workspace/src/ui/diff.rs index 34917f8d680..89f1a0d9fce 100644 --- a/crates/but-workspace/src/ui/diff.rs +++ b/crates/but-workspace/src/ui/diff.rs @@ -10,8 +10,7 @@ pub fn changes_in_branch( branch: &gix::refs::FullNameRef, ) -> anyhow::Result { let commits = if let Some((_, segment)) = workspace.find_segment_and_stack_by_refname(branch) { - let base = segment.base; - segment.tip().zip(base) + segment.tip().zip(segment.base) } else { // TODO: this should be (kept) in sync with branch-listing! let tip = repo.find_reference(branch)?.peel_to_commit()?.id; diff --git a/crates/but-workspace/src/upstream_integration.rs b/crates/but-workspace/src/upstream_integration.rs index ea7d5022dba..95a109306da 100644 --- a/crates/but-workspace/src/upstream_integration.rs +++ b/crates/but-workspace/src/upstream_integration.rs @@ -6,13 +6,10 @@ use anyhow::{Context, Result, bail}; use but_core::{RefMetadata, branch::unique_canned_refname, ref_metadata::ProjectMeta}; use but_graph::workspace::commit::is_managed_workspace_by_message; -use but_rebase::{ - commit::DateMode, - graph_rebase::{ - Editor, LookupStep, Pick, Selector, Step, SuccessfulRebase, ToSelector, - mutate::InsertSide, - selector::{RelativeTo, SelectorSet, StepRange}, - }, +use but_rebase::graph_rebase::{ + Editor, LookupStep, Pick, Selector, Step, SuccessfulRebase, ToSelector, + mutate::{InsertSide, Reconnect}, + selector::{RelativeTo, SelectorSet, StepRange}, }; use crate::changeset::compute_similarity_by_commit_ids; @@ -222,11 +219,15 @@ pub fn integrate_upstream_with_hints<'meta, M: RefMetadata>( head_commit, head_is_workspace_commit, &editor, - from_target_sha, - from_target_ref, - target_sha, - target_ref.ref_name.as_ref(), - target_ref_commit.detach(), + TargetReach { + from_sha: from_target_sha, + from_ref: from_target_ref, + }, + TargetRef { + sha: target_sha, + ref_name: target_ref.ref_name.as_ref(), + ref_commit: target_ref_commit.detach(), + }, review_hints, )?; @@ -351,22 +352,21 @@ pub fn integrate_upstream_with_hints<'meta, M: RefMetadata>( // Disconnect all stack heads from the workspace commit, if any. if let Some(workspace_commit_selector) = workspace_commit_selector { for selector in &fully_integrated_workspace_parents { - editor.remove_edges(workspace_commit_selector, *selector)?; + editor.detach(workspace_commit_selector, *selector)?; } let direct_parents = editor.direct_parents(workspace_commit_selector)?; match direct_parents.as_slice() { - [(parent_selector, parent_order)] + [(parent_selector, _)] if fully_integrated_workspace_parents.is_empty() && selector_commit_id(&editor, *parent_selector)? == Some(target_sha) && target_sha != target_ref_commit.detach() => { // Only parent is the old target sha, and that's not the latest tip of the target ref. // We need to reparent it onto the latest target ref. - editor.remove_edges(workspace_commit_selector, *parent_selector)?; - editor.insert_edge( + editor.reparent_edges( workspace_commit_selector, + *parent_selector, target_ref_selector, - *parent_order, )?; } [] if !fully_integrated_workspace_parents.is_empty() => { @@ -391,10 +391,8 @@ pub fn integrate_upstream_with_hints<'meta, M: RefMetadata>( Step::Reference { .. } => InsertSide::Below, }; - let mut merge_commit = editor.empty_commit()?; - merge_commit.message = format!("Merge {} into merge", target_ref.ref_name).into(); let merge_commit = - editor.new_commit(merge_commit, DateMode::CommitterKeepAuthorKeep)?; + editor.new_merge_commit(format!("Merge {} into merge", target_ref.ref_name))?; let merge_commit = editor.insert( *head, Step::Pick(Pick::new_untracked_pick(merge_commit)), @@ -442,13 +440,7 @@ pub fn integrate_upstream_with_hints<'meta, M: RefMetadata>( } for (child, parent) in edges_to_replace { - let removed = editor.remove_edges(child, parent)?; - // Put the target ref where the lowest removed parent number was. - // We could add back multiple, but it's likely unintentional - // that there were two parents in the first place. - if let Some(parent_number) = removed.iter().min() { - editor.insert_edge(child, target_ref_selector, *parent_number)?; - } + editor.reparent_edges(child, parent, target_ref_selector)?; } } } @@ -462,16 +454,29 @@ pub fn integrate_upstream_with_hints<'meta, M: RefMetadata>( }) } -#[allow(clippy::too_many_arguments)] +/// The integration target's coordinates: its recorded sha, its ref, and the ref's current +/// commit โ€” bundled so the two same-typed object ids can't be transposed across the helpers +/// that compare one against the other. +#[derive(Clone, Copy)] +struct TargetRef<'a> { + sha: gix::ObjectId, + ref_name: &'a gix::refs::FullNameRef, + ref_commit: gix::ObjectId, +} + +/// The graph selectors reachable from the target's sha and from its ref โ€” the stack walk's +/// two stop-sets, bundled so the pair can't be swapped at the call. +struct TargetReach { + from_sha: HashSet, + from_ref: HashSet, +} + fn collect_stacks<'meta, M: RefMetadata>( head_commit: gix::Commit<'_>, head_is_workspace_commit: bool, editor: &Editor<'meta, M>, - from_target_sha: HashSet, - from_target_ref: HashSet, - target_sha: gix::ObjectId, - target_ref_name: &gix::refs::FullNameRef, - target_ref_commit: gix::ObjectId, + reach: TargetReach, + target: TargetRef<'_>, review_hints: &[ReviewIntegrationHint], ) -> Result> { let mut stacks = if head_is_workspace_commit { @@ -499,7 +504,7 @@ fn collect_stacks<'meta, M: RefMetadata>( while let Some(tip) = tips.pop() { for parent in editor.position_parents(tip)? { - if from_target_sha.contains(&parent) { + if reach.from_sha.contains(&parent) { continue; } @@ -524,12 +529,13 @@ fn collect_stacks<'meta, M: RefMetadata>( output_stacks.push(out); } - let upstream_selectors = from_target_ref + let upstream_selectors = reach + .from_ref .iter() - .filter_map(|s| (!from_target_sha.contains(s)).then_some(*s)) + .filter_map(|s| (!reach.from_sha.contains(s)).then_some(*s)) .collect::>(); let upstream_selectors = if upstream_selectors.is_empty() { - from_target_ref.iter().copied().collect() + reach.from_ref.iter().copied().collect() } else { upstream_selectors }; @@ -562,7 +568,7 @@ fn collect_stacks<'meta, M: RefMetadata>( // Reachability also marks REFERENCES: a ref chain reachable from the target lies // on integrated history (its anchor commit is integrated), so this is anchor-based // integration expressed through the graph walk. - if from_target_ref.contains(node) { + if reach.from_ref.contains(node) { attrs.historically_integrated = true; } @@ -638,9 +644,7 @@ fn collect_stacks<'meta, M: RefMetadata>( *r_sel, r_name.as_ref(), &reference_nodes, - target_sha, - target_ref_name, - target_ref_commit, + target, )?; if traversed_commits { // The remote-tip check is only an empty-segment fallback. Once @@ -667,15 +671,12 @@ fn collect_stacks<'meta, M: RefMetadata>( /// target branch itself, rejects stale tracking refs that still point at the old /// target, and preserves an empty branch if it sits on top of another local /// branch that is not itself target-integrated. -#[allow(clippy::too_many_arguments)] fn empty_local_reference_remote_tip_integrated<'meta, M: RefMetadata>( editor: &Editor<'meta, M>, selector: Selector, ref_name: &gix::refs::FullNameRef, reference_nodes: &HashMap, - target_sha: gix::ObjectId, - target_ref_name: &gix::refs::FullNameRef, - target_ref_commit: gix::ObjectId, + target: TargetRef<'_>, ) -> Result { if ref_name.category() != Some(gix::refs::Category::LocalBranch) { return Ok(false); @@ -688,12 +689,7 @@ fn empty_local_reference_remote_tip_integrated<'meta, M: RefMetadata>( if editor.position_parents(selector)?.iter().any(|parent| { reference_nodes.get(parent).is_some_and(|parent_ref| { parent_ref.category() == Some(gix::refs::Category::LocalBranch) - && !reference_points_to_target( - editor.repo(), - parent_ref.as_ref(), - target_sha, - target_ref_commit, - ) + && !reference_points_to_target(editor.repo(), parent_ref.as_ref(), target) }) }) { return Ok(false); @@ -702,7 +698,7 @@ fn empty_local_reference_remote_tip_integrated<'meta, M: RefMetadata>( let Ok(remote_ref_name) = resolve_tracking_branch_ref_name(ref_name, editor.repo()) else { return Ok(false); }; - if remote_ref_name.as_bstr() == target_ref_name.as_bstr() { + if remote_ref_name.as_bstr() == target.ref_name.as_bstr() { return Ok(false); } @@ -714,13 +710,13 @@ fn empty_local_reference_remote_tip_integrated<'meta, M: RefMetadata>( else { return Ok(false); }; - if remote_tip_id == target_sha && remote_tip_id != target_ref_commit { + if remote_tip_id == target.sha && remote_tip_id != target.ref_commit { return Ok(false); } Ok(editor .repo() - .merge_base(remote_tip_id, target_ref_commit) + .merge_base(remote_tip_id, target.ref_commit) .is_ok_and(|merge_base| merge_base.detach() == remote_tip_id)) } @@ -733,8 +729,7 @@ fn empty_local_reference_remote_tip_integrated<'meta, M: RefMetadata>( fn reference_points_to_target( repo: &gix::Repository, ref_name: &gix::refs::FullNameRef, - target_sha: gix::ObjectId, - target_ref_commit: gix::ObjectId, + target: TargetRef<'_>, ) -> bool { repo.try_find_reference(ref_name) .ok() @@ -742,7 +737,7 @@ fn reference_points_to_target( .and_then(|mut reference| reference.peel_to_id().ok()) .map(|id| { let id = id.detach(); - id == target_sha || id == target_ref_commit + id == target.sha || id == target.ref_commit }) .unwrap_or(false) } @@ -935,7 +930,7 @@ fn replace_direct_checkout_ref_with_fallback( }, SelectorSet::All, SelectorSet::All, - false, + Reconnect::Heal, )?; preserve_pick_parents(editor, target_tip_selector)?; editor.insert_edge(head_ref_selector, target_tip_selector, 0)?; diff --git a/crates/but-workspace/src/workspace.rs b/crates/but-workspace/src/workspace.rs index 53e0d793dd6..7d55d9a4d64 100644 --- a/crates/but-workspace/src/workspace.rs +++ b/crates/but-workspace/src/workspace.rs @@ -91,10 +91,10 @@ pub struct DetailedGraphWorkspace { /// project the rebase's already-mutated commit graph directly, serving its pending ref edits /// and entrypoint from the overlay โ€” no repository rewalk. pub fn overlayed_workspace( - workspace: &but_graph::Workspace, + substrate: &but_graph::Workspace, rebase: &but_rebase::graph_rebase::SuccessfulRebase<'_, M>, ) -> Result { - workspace.preview_from_commit_graph( + substrate.preview_from_commit_graph( rebase.arena().clone(), rebase.repo(), rebase.meta(), @@ -114,7 +114,7 @@ pub fn detailed_graph_workspace( meta, repo, )?; - let ws = editor.graph_workspace()?; + let ws = editor.graph_workspace(&workspace.segment_graph)?; Ok(DetailedGraphWorkspace { stacks: ws diff --git a/crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-chain-below-bound.sh b/crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-chain-below-bound.sh new file mode 100755 index 00000000000..c06ee56a633 --- /dev/null +++ b/crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-chain-below-bound.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +set -eu -o pipefail + +source "${BASH_SOURCE[0]%/*}/shared.sh" + +### General Description + +# A ws-ref points to a workspace commit over one stack (A with a commit), while a second +# declared chain has nothing above the workspace lower bound: B rests on an unreachable +# side leg, and D rests on the base commit BELOW the bound (origin/main advanced past it). +git init +commit M +git branch D + +git checkout -b side + commit S +git branch B +git checkout main + commit M2 + +setup_target_to_match_main + +# Advance origin/main past M2, making M2 the workspace lower bound and M integrated +# territory below it. +tick +git update-ref refs/remotes/origin/main "$(git commit-tree -p "$(git rev-parse refs/remotes/origin/main)" -m 'advanced' "$(git rev-parse main^{tree})")" + +git checkout -b A + commit A + +create_workspace_commit_once A diff --git a/crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-empties-between-owners.sh b/crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-empties-between-owners.sh new file mode 100755 index 00000000000..572e00fba36 --- /dev/null +++ b/crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-empties-between-owners.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +set -eu -o pipefail + +source "${BASH_SOURCE[0]%/*}/shared.sh" + +### General Description + +# A ws-ref points to a workspace commit over two stacks: +# - Stack 1 interleaves empty branches with commit owners: +# A (file-a) <- e1 (empty) <- B (file-b), with e2 and e3 both empty on B's tip. +# - Stack 2 is a single commit-owning branch F (file-f). +git init +echo base >base +git add base +git commit -m M +setup_target_to_match_main + +git checkout -b A + echo a >file-a + git add file-a + git commit -m A +git branch e1 +git checkout -b B + echo b >file-b + git add file-b + git commit -m B +git branch e2 +git branch e3 + +git checkout main +git checkout -b F + echo f >file-f + git add file-f + git commit -m F + +create_workspace_commit_once e3 F diff --git a/crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-empty-at-own-leg-base.sh b/crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-empty-at-own-leg-base.sh new file mode 100755 index 00000000000..6af1c3edfbc --- /dev/null +++ b/crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-empty-at-own-leg-base.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +set -eu -o pipefail + +source "${BASH_SOURCE[0]%/*}/shared.sh" + +### General Description + +# A ws-ref over one stack (E with a commit), whose declared chain [A, E] also lists A: +# an empty branch resting exactly on E's own leg base โ€” the target tip main/origin/main +# share. The chain's content (E's run) is the carrier for A's splice. +git init +commit M +commit M2 + +setup_target_to_match_main +git branch A + +git checkout -b E + commit E1 + +create_workspace_commit_once E diff --git a/crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-empty-at-unpulled-target.sh b/crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-empty-at-unpulled-target.sh new file mode 100755 index 00000000000..ca0b2314bc0 --- /dev/null +++ b/crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-empty-at-unpulled-target.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash + +set -eu -o pipefail + +source "${BASH_SOURCE[0]%/*}/shared.sh" + +### General Description + +# A ws-ref points to a workspace commit over one stack (B with a commit), while the +# stack's declared chain also lists E: an empty branch resting exactly at the target's +# UNPULLED tip โ€” main and origin/main both advanced one commit past the workspace's +# base, so the target's local shares E's commit. +git init +commit M + +setup_target_to_match_main + +git checkout -b B + commit B1 + +create_workspace_commit_once B + +# Advance main and origin/main one commit past M without updating the workspace. +tick +advanced=$(git commit-tree -p "$(git rev-parse main)" -m 'advanced' "$(git rev-parse main^{tree})") +git update-ref refs/heads/main "$advanced" +git update-ref refs/remotes/origin/main "$advanced" + +# E rests exactly at the unpulled target tip. +git branch E "$advanced" diff --git a/crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-empty-inside-anonymous-lane.sh b/crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-empty-inside-anonymous-lane.sh new file mode 100755 index 00000000000..b8f8fbe4de5 --- /dev/null +++ b/crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-empty-inside-anonymous-lane.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +set -eu -o pipefail + +source "${BASH_SOURCE[0]%/*}/shared.sh" + +### General Description + +# A ws-ref over one ANONYMOUS lane (its tip ref was deleted after the workspace commit), +# whose declared chain [A, D] has content only OUTSIDE the workspace: A rests on a side +# leg off the base, D rests empty on the lane's parent c3 โ€” the target's local (main) +# position, with origin/main advanced one commit past it. +git init +commit M + +setup_target_to_match_main + +tick +tree=$(git rev-parse main^{tree}) +base=$(git rev-parse main) +c3=$(git commit-tree -p "$base" -m 'c3' "$tree") +git branch D "$c3" +tick +c4=$(git commit-tree -p "$c3" -m 'c4' "$tree") +tick +c2=$(git commit-tree -p "$base" -m 'c2' "$tree") +git branch A "$c2" + +git branch lane "$c4" +git checkout lane +create_workspace_commit_once lane +git branch -D lane + +# main catches up to c3; origin/main advances one past it. +git update-ref refs/heads/main "$c3" +tick +adv=$(git commit-tree -p "$c3" -m 'advanced' "$tree") +git update-ref refs/remotes/origin/main "$adv" diff --git a/crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-region-boundary-mid-outside-run.sh b/crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-region-boundary-mid-outside-run.sh new file mode 100755 index 00000000000..f56a1a1719e --- /dev/null +++ b/crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-region-boundary-mid-outside-run.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +set -eu -o pipefail + +source "${BASH_SOURCE[0]%/*}/shared.sh" + +### General Description + +# A ws-ref over one empty lane (W at the base), while the target advanced onto a merge +# whose side leg runs through an applied branch's territory: A rests two commits up a +# side leg (c4 <- c1 <- base), C and D rest mid-leg on c1, and c1 is also a fork (X's +# leg). The target region's boundary at c1 lands MID-RUN of A's advanced-outside run. +git init +commit M + +setup_target_to_match_main + +git checkout -b W +create_workspace_commit_once W + +tick +base=$(git rev-parse main) +tree=$(git rev-parse main^{tree}) +c1=$(git commit-tree -p "$base" -m 'c1' "$tree") +git branch C "$c1" +git branch D "$c1" +tick +c3=$(git commit-tree -p "$c1" -m 'c3' "$tree") +git branch X "$c3" +tick +c4=$(git commit-tree -p "$c1" -m 'c4' "$tree") +git branch A "$c4" +tick +c5=$(git commit-tree -p "$base" -p "$c4" -m 'c5 merge' "$tree") +tick +adv=$(git commit-tree -p "$c5" -m 'advanced' "$tree") +git update-ref refs/heads/main "$c5" +git update-ref refs/remotes/origin/main "$adv" diff --git a/crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-two-stacks-shared-parent.sh b/crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-two-stacks-shared-parent.sh new file mode 100755 index 00000000000..f4717708c06 --- /dev/null +++ b/crates/but-workspace/tests/fixtures/scenario/ws-ref-ws-commit-two-stacks-shared-parent.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +set -eu -o pipefail + +source "${BASH_SOURCE[0]%/*}/shared.sh" + +### General Description + +# Two stacks over one shared parent branch: A carries a commit, B-on-A and C-on-A each +# add one on top of it, and the ws commit merges both tips. The shared A segment +# legitimately displays under BOTH stacks. +git init +commit M + +setup_target_to_match_main + +git checkout -b A + commit A1 + +git checkout -b B-on-A + commit B1 + +git checkout -b C-on-A A + commit C1 + +create_workspace_commit_once B-on-A C-on-A diff --git a/crates/but-workspace/tests/workspace/branch/apply_unapply.rs b/crates/but-workspace/tests/workspace/branch/apply_unapply.rs index a5826eb52f6..9540f200ff0 100644 --- a/crates/but-workspace/tests/workspace/branch/apply_unapply.rs +++ b/crates/but-workspace/tests/workspace/branch/apply_unapply.rs @@ -86,17 +86,16 @@ fn operation_denied_on_improper_workspace() -> anyhow::Result<()> { ); let branch_b = r("refs/heads/B"); - let err = but_workspace::branch::apply(branch_b, ws.clone(), &repo, &mut meta, apply_options()) - .unwrap_err(); + let err = + but_workspace::branch::apply(branch_b, &ws, &repo, &mut meta, apply_options()).unwrap_err(); assert_eq!( err.to_string(), "Refusing to work on workspace whose workspace commit isn't at the top", "cannot apply on a workspace that isn't proper" ); - let err = - but_workspace::branch::apply(r("HEAD"), ws.clone(), &repo, &mut meta, apply_options()) - .unwrap_err(); + let err = but_workspace::branch::apply(r("HEAD"), &ws, &repo, &mut meta, apply_options()) + .unwrap_err(); assert_eq!( err.to_string(), "Refusing to apply symbolic ref 'HEAD' due to potential ambiguity" @@ -224,7 +223,7 @@ fn unapply_branch_from_named_ad_hoc_workspace_affects_metadata() -> anyhow::Resu stack_id_for_name, None, )? - .into_owned(); + .expect("the workspace changed"); // creating a branch in the ad-hoc workspace gives unapply a visible segment to reject, it has its own ref-metadata snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -252,7 +251,7 @@ fn unapply_branch_from_named_ad_hoc_workspace_affects_metadata() -> anyhow::Resu // on-A1 no longer has a metadata-disambiguated segment snapbox::assert_data_eq!( - graph_workspace(&out.workspace).to_string(), + graph_workspace(&out.display_workspace()?).to_string(), snapbox::str![[r#" โŒ‚:A2 <> โœ“! โ””โ”€โ”€ โ‰ก:A2 {1} @@ -309,7 +308,7 @@ fn ws_ref_no_ws_commit_two_virtual_stacks_on_same_commit_apply_dependent_first() // Put "B" into the workspace, even though it's the dependent branch of A. let out = - but_workspace::branch::apply(r("refs/heads/B"), ws, &repo, &mut meta, apply_options())?; + but_workspace::branch::apply(r("refs/heads/B"), &ws, &repo, &mut meta, apply_options())?; snapbox::assert_data_eq!( out.to_debug(), snapbox::str![[r#" @@ -321,7 +320,7 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -334,10 +333,10 @@ Outcome { // Applying A is always a new stack then. let out = - but_workspace::branch::apply(r("refs/heads/A"), ws, &repo, &mut meta, apply_options())?; + but_workspace::branch::apply(r("refs/heads/A"), &ws, &repo, &mut meta, apply_options())?; // the workspace ref still points to the base e5d0542 snapbox::assert_data_eq!( - graph_workspace(&out.workspace).to_string(), + graph_workspace(&out.display_workspace()?).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on e5d0542 {1} @@ -357,7 +356,7 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; let out = but_workspace::branch::unapply( r("refs/heads/A"), &ws, @@ -375,7 +374,7 @@ Outcome { "#]] ); - let ws = out.workspace.into_owned(); + let ws = out.display_workspace()?; // A was removed with metadata only snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -407,7 +406,7 @@ Outcome { ); // no stacks are left snapbox::assert_data_eq!( - graph_workspace(&out.workspace).to_string(), + graph_workspace(&out.display_workspace()?).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 @@ -440,7 +439,11 @@ mod workspace_disposition { unapply_options_with(WorkspaceDisposition::KeepWorkspaceCommit), )?; assert!( - out.workspace.kind.has_managed_commit(), + out.workspace + .as_ref() + .expect("workspace changed") + .kind + .has_managed_commit(), "KeepWorkspaceCommit must preserve the checked-out managed workspace commit" ); assert!( @@ -449,7 +452,7 @@ mod workspace_disposition { ); // A was removed snapbox::assert_data_eq!( - graph_workspace(&out.workspace).to_string(), + graph_workspace(&out.display_workspace()?).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 โ”œโ”€โ”€ โ‰ก๐Ÿ“™:virtual-base on 85efbe4 {1} @@ -465,7 +468,7 @@ mod workspace_disposition { visualize_commit_graph_all(&repo)?, snapbox::str![[r#" * 09d8e52 (A) A -| * 3b77768 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +| * 78957ac (HEAD -> gitbutler/workspace) GitButler Workspace Commit |/| | * c813d8d (B) B |/ @@ -494,7 +497,7 @@ mod workspace_disposition { ); // There still is a workspace merge commit, we have two stacks, virtual or not snapbox::assert_data_eq!( - graph_workspace(&out.workspace).to_string(), + graph_workspace(&out.display_workspace()?).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 โ”œโ”€โ”€ โ‰ก๐Ÿ“™:virtual-base on 85efbe4 {1} @@ -510,7 +513,7 @@ mod workspace_disposition { visualize_commit_graph_all(&repo)?, snapbox::str![[r#" * 09d8e52 (A) A -| * 3b77768 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +| * 78957ac (HEAD -> gitbutler/workspace) GitButler Workspace Commit |/| | * c813d8d (B) B |/ @@ -566,7 +569,7 @@ Outcome { ); // the projection is the checked-out remaining stack snapbox::assert_data_eq!( - graph_workspace(&out.workspace).to_string(), + graph_workspace(&out.display_workspace()?).to_string(), snapbox::str![[r#" โŒ‚:B[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 โ””โ”€โ”€ โ‰ก๐Ÿ“™:B[๐ŸŒณ] on 85efbe4 {1} @@ -604,7 +607,11 @@ Outcome { "#]] ); assert!( - out.workspace.kind.has_managed_commit(), + out.workspace + .as_ref() + .expect("workspace changed") + .kind + .has_managed_commit(), "compatibility disposition should preserve the managed workspace commit when the workspace ref remains" ); assert!( @@ -612,7 +619,7 @@ Outcome { "compatibility disposition should rebuild the workspace merge instead of collapsing the ref to the last real stack" ); snapbox::assert_data_eq!( - graph_workspace(&out.workspace).to_string(), + graph_workspace(&out.display_workspace()?).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {2} @@ -627,7 +634,7 @@ Outcome { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* 483c8bd (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 3147679 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ | * c813d8d (B) B * | 09d8e52 (A) A @@ -655,9 +662,14 @@ Outcome { project_meta(&meta), standard_traversal_options(), )?; - let out = - but_workspace::branch::apply(r("refs/heads/A"), ws, &repo, &mut meta, apply_options())?; - let ws = out.workspace; + let out = but_workspace::branch::apply( + r("refs/heads/A"), + &ws, + &repo, + &mut meta, + apply_options(), + )?; + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -691,7 +703,7 @@ Outcome { // everything is dissolved, there is no gitbutler/workspace ref either snapbox::assert_data_eq!( - graph_workspace(&out.workspace).to_string(), + graph_workspace(&out.display_workspace()?).to_string(), snapbox::str![[r#" โŒ‚:main[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e5d0542 โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] <> origin/main {1} @@ -717,9 +729,14 @@ Outcome { project_meta(&meta), standard_traversal_options(), )?; - let out = - but_workspace::branch::apply(r("refs/heads/A"), ws, &repo, &mut meta, apply_options())?; - let ws = out.workspace; + let out = but_workspace::branch::apply( + r("refs/heads/A"), + &ws, + &repo, + &mut meta, + apply_options(), + )?; + let ws = out.display_workspace()?; let out = but_workspace::branch::unapply( r("refs/heads/A"), @@ -745,7 +762,7 @@ Outcome { // the compatibility mode still removes the whole workspace when it can snapbox::assert_data_eq!( - graph_workspace(&out.workspace).to_string(), + graph_workspace(&out.display_workspace()?).to_string(), snapbox::str![[r#" โŒ‚:main[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e5d0542 โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] <> origin/main {1} @@ -771,9 +788,14 @@ Outcome { project_meta(&meta), standard_traversal_options(), )?; - let out = - but_workspace::branch::apply(r("refs/heads/A"), ws, &repo, &mut meta, apply_options())?; - let ws = out.workspace; + let out = but_workspace::branch::apply( + r("refs/heads/A"), + &ws, + &repo, + &mut meta, + apply_options(), + )?; + let ws = out.display_workspace()?; // the workspace ref is checked out snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -864,7 +886,7 @@ Outcome { ); // the workspace is empty snapbox::assert_data_eq!( - graph_workspace(&out.workspace).to_string(), + graph_workspace(&out.display_workspace()?).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 @@ -876,7 +898,7 @@ Outcome { snapbox::str![[r#" * d69fe94 (B) B * 09d8e52 (A) A -| * bde8ed6 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +| * 0683d87 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |/ * 85efbe4 (origin/main, main) M @@ -997,7 +1019,7 @@ fn main_with_advanced_remote_tracking_branch() -> anyhow::Result<()> { // We cannot apply remote tracking branches directly, but it resolves automatically to local tracking branches. let out = but_workspace::branch::apply( r("refs/remotes/origin/main"), - ws.clone(), + &ws, &repo, &mut meta, apply_options(), @@ -1018,7 +1040,7 @@ Outcome { // Set up an automatic tracking of origin/feature, as remote tracking branches can't be in the workspace. let out = but_workspace::branch::apply( r("refs/remotes/origin/feature"), - ws, + &ws, &repo, &mut meta, apply_options(), @@ -1034,7 +1056,7 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; // both branches, main and feature, are available in the newly created workspace ref. snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -1053,7 +1075,7 @@ Outcome { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* 3d23cfb (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 6e8385b (HEAD -> gitbutler/workspace) GitButler Workspace Commit * 6b40b15 (origin/feature, feature) without-local-tracking | * 552e7dc (origin/main) only-on-remote |/ @@ -1104,8 +1126,8 @@ fn unapply_remotely_tracked_tip_of_multi_segment_stack_can_delete_workspace_ref( } let out = - but_workspace::branch::apply(r("refs/heads/B"), ws, &repo, &mut meta, apply_options())?; - let ws = out.workspace; + but_workspace::branch::apply(r("refs/heads/B"), &ws, &repo, &mut meta, apply_options())?; + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1153,7 +1175,7 @@ Outcome { // an ad-hoc workspace remains on the target's local tracking branch snapbox::assert_data_eq!( - graph_workspace(&out.workspace).to_string(), + graph_workspace(&out.display_workspace()?).to_string(), snapbox::str![[r#" โŒ‚:main[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] <> origin/main {1} @@ -1209,7 +1231,7 @@ fn workspace_with_out_of_ws_ref_and_anon_stack() -> anyhow::Result<()> { let out = but_workspace::branch::apply( r("refs/heads/feature"), - ws, + &ws, &repo, &mut meta, apply_options(), @@ -1227,7 +1249,7 @@ Outcome { ); snapbox::assert_data_eq!( - graph_workspace(&out.workspace).to_string(), + graph_workspace(&out.display_workspace()?).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 3183e43 โ”œโ”€โ”€ โ‰ก๐Ÿ“™:outside on 3183e43 {1} @@ -1270,7 +1292,7 @@ fn ws_ref_no_ws_commit_two_stacks_on_same_commit() -> anyhow::Result<()> { // Put "A" into the workspace, yielding a single branch. let out = - but_workspace::branch::apply(r("refs/heads/A"), ws, &repo, &mut meta, apply_options())?; + but_workspace::branch::apply(r("refs/heads/A"), &ws, &repo, &mut meta, apply_options())?; snapbox::assert_data_eq!( out.to_debug(), snapbox::str![[r#" @@ -1282,7 +1304,7 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1302,7 +1324,7 @@ Outcome { ); let branch_b = r("refs/heads/B"); - let out = but_workspace::branch::apply(branch_b, ws, &repo, &mut meta, apply_options())?; + let out = but_workspace::branch::apply(branch_b, &ws, &repo, &mut meta, apply_options())?; snapbox::assert_data_eq!( out.to_debug(), snapbox::str![[r#" @@ -1316,7 +1338,7 @@ Outcome { ); // Note how it will create a new stack (to keep it simple), // in theory we could also add B as dependent branch. - let ws = out.workspace; + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1339,7 +1361,7 @@ Outcome { ); let out = but_workspace::branch::unapply(branch_b, &ws, &repo, &mut meta, unapply_options())?; - let ws = out.workspace.into_owned(); + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1365,7 +1387,7 @@ Outcome { unapply_options(), )?; snapbox::assert_data_eq!( - graph_workspace(&out.workspace).to_string(), + graph_workspace(&out.display_workspace()?).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 @@ -1416,7 +1438,7 @@ fn unapply_natural_stack_with_partial_workspace_metadata() -> anyhow::Result<()> // A is removed and B is left snapbox::assert_data_eq!( - graph_workspace(&out.workspace).to_string(), + graph_workspace(&out.display_workspace()?).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {1} @@ -1426,7 +1448,13 @@ fn unapply_natural_stack_with_partial_workspace_metadata() -> anyhow::Result<()> "#]] ); - let ws_md = out.workspace.metadata.as_ref().expect("present"); + let ws_md = out + .workspace + .as_ref() + .expect("workspace changed") + .metadata + .as_ref() + .expect("present"); // the extra segment is still present in metadata to help with stack identities snapbox::assert_data_eq!( &ws_md.to_debug(), @@ -1493,11 +1521,11 @@ fn unapply_natural_stack_branch_without_workspace_metadata() -> anyhow::Result<( // C was unapplied, and the workspace commit removed snapbox::assert_data_eq!( - graph_workspace_determinisitcally(&out.workspace).to_string(), + graph_workspace_determinisitcally(&out.display_workspace()?).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 893d602 -โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 893d602 {1} - โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก:A on 893d602 + โ””โ”€โ”€ :A โ””โ”€โ”€ ยท26e45af (๐Ÿ˜๏ธ) "#]] @@ -1515,39 +1543,20 @@ fn unapply_natural_stack_branch_without_workspace_metadata() -> anyhow::Result<( "#]] ); - let ws_md = out.workspace.metadata.as_ref().expect("present"); + let ws_md = out + .workspace + .as_ref() + .expect("workspace changed") + .metadata + .as_ref() + .expect("present"); // the metadata matches the real workspace now snapbox::assert_data_eq!( sanitize_uuids_and_timestamps(format!("{ws_md:#?}")), snapbox::str![[r#" Workspace { ref_info: RefInfo { created_at: "2023-01-31 14:55:57 +0000", updated_at: None }, - stacks: [ - WorkspaceStack { - id: 1, - branches: [ - WorkspaceStackBranch { - ref_name: "refs/heads/A", - archived: false, - }, - ], - workspacecommit_relation: Merged, - }, - WorkspaceStack { - id: 2, - branches: [ - WorkspaceStackBranch { - ref_name: "refs/heads/C", - archived: false, - }, - WorkspaceStackBranch { - ref_name: "refs/heads/B", - archived: false, - }, - ], - workspacecommit_relation: Outside, - }, - ], + stacks: [], target_ref: "refs/remotes/origin/main", target_commit_id: Sha1(893d6025c9de29ed75369de967e52a1154bbf0ee), push_remote: None, @@ -1607,7 +1616,7 @@ fn no_ws_ref_no_ws_commit_two_stacks_on_same_commit_ad_hoc_workspace_without_tar // Put "A" into the workspace, creating the workspace ref, but never put a branch related to the target in as well, // which is currently checked out with `main`. let out = - but_workspace::branch::apply(r("refs/heads/A"), ws, &repo, &mut meta, apply_options())?; + but_workspace::branch::apply(r("refs/heads/A"), &ws, &repo, &mut meta, apply_options())?; snapbox::assert_data_eq!( out.to_debug(), snapbox::str![[r#" @@ -1621,7 +1630,7 @@ Outcome { ); snapbox::assert_data_eq!( - graph_workspace(&out.workspace).to_string(), + graph_workspace(&out.display_workspace()?).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 โ”œโ”€โ”€ โ‰ก๐Ÿ“™:main on e5d0542 {1a5} @@ -1642,10 +1651,10 @@ Outcome { ); // Thread the post-apply workspace into the next apply instead of reusing the stale projection. - let ws = out.workspace; + let ws = out.display_workspace()?; let out = but_workspace::branch::apply( r("refs/heads/B"), - ws, + &ws, &repo, &mut meta, but_workspace::branch::apply::Options { @@ -1665,7 +1674,7 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1710,7 +1719,7 @@ Outcome { let out = but_workspace::branch::apply( r("refs/heads/A"), - ws, + &ws, &repo, &mut meta, but_workspace::branch::apply::Options { @@ -1722,13 +1731,13 @@ Outcome { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* 6277161 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 3e555fe (HEAD -> gitbutler/workspace) GitButler Workspace Commit * e5d0542 (origin/main, main, B, A) A "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1742,7 +1751,7 @@ Outcome { let out = but_workspace::branch::apply( r("refs/heads/B"), - ws, + &ws, &repo, &mut meta, but_workspace::branch::apply::Options { @@ -1755,7 +1764,7 @@ Outcome { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* 5ecf7a4 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 664f5f7 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ * e5d0542 (origin/main, main, B, A) A @@ -1763,7 +1772,7 @@ Outcome { .raw() ); - let ws = out.workspace; + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1816,7 +1825,7 @@ fn no_ws_ref_no_ws_commit_two_stacks_on_same_commit_ad_hoc_workspace_with_target // Put "A" into the workspace, creating the workspace ref, but never put a branch related to the target in as well, // which is currently checked out with `main`. let out = - but_workspace::branch::apply(r("refs/heads/A"), ws, &repo, &mut meta, apply_options())?; + but_workspace::branch::apply(r("refs/heads/A"), &ws, &repo, &mut meta, apply_options())?; snapbox::assert_data_eq!( out.to_debug(), snapbox::str![[r#" @@ -1829,7 +1838,7 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1850,7 +1859,7 @@ Outcome { ); let out = - but_workspace::branch::apply(r("refs/heads/B"), ws, &repo, &mut meta, apply_options())?; + but_workspace::branch::apply(r("refs/heads/B"), &ws, &repo, &mut meta, apply_options())?; snapbox::assert_data_eq!( out.to_debug(), snapbox::str![[r#" @@ -1862,7 +1871,7 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1886,9 +1895,8 @@ Outcome { // Cannot put local tracking branch of target into workspace that has it configured. for branch in ["refs/heads/main", "refs/remotes/origin/main"] { - let err = - but_workspace::branch::apply(r(branch), ws.clone(), &repo, &mut meta, apply_options()) - .unwrap_err(); + let err = but_workspace::branch::apply(r(branch), &ws, &repo, &mut meta, apply_options()) + .unwrap_err(); assert_eq!( err.to_string(), format!("Cannot add the target '{branch}' branch to its own workspace") @@ -1909,7 +1917,7 @@ Outcome { &mut meta, unapply_options(), )?; - let ws = out.workspace.into_owned(); + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1936,7 +1944,7 @@ Outcome { )?; // the target's local tracking branch is checked out snapbox::assert_data_eq!( - graph_workspace(&out.workspace).to_string(), + graph_workspace(&out.display_workspace()?).to_string(), snapbox::str![[r#" โŒ‚:main[๐ŸŒณ] <> โœ“refs/remotes/origin/main on e5d0542 โ””โ”€โ”€ โ‰ก:main[๐ŸŒณ] <> origin/main {1} @@ -1981,7 +1989,7 @@ fn apply_after_switching_out_of_workspace_drops_stale_stacks() -> anyhow::Result // current branch plus the applied one, while the stale `outside` stack is demoted. let out = but_workspace::branch::apply( r("refs/heads/applied"), - ws, + &ws, &repo, &mut meta, apply_options(), @@ -2118,14 +2126,14 @@ fn apply_from_enclosed_adhoc_workspace_rebuilds_around_current_and_applied() -> ); let out = - but_workspace::branch::apply(r("refs/heads/C"), ws, &repo, &mut meta, apply_options())?; + but_workspace::branch::apply(r("refs/heads/C"), &ws, &repo, &mut meta, apply_options())?; assert_eq!(out.status, OutcomeStatus::Applied); // applying C rebuilds the workspace around B and C snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" * ccf539c (A) A -| * 7ed2c6c (HEAD -> gitbutler/workspace) GitButler Workspace Commit +| * acd98b7 (HEAD -> gitbutler/workspace) GitButler Workspace Commit | |\ | | * 863775d (C) add C | |/ @@ -2279,14 +2287,14 @@ fn apply_from_adhoc_checkout_rebuilds_around_current_and_applied() -> anyhow::Re ); let out = - but_workspace::branch::apply(r("refs/heads/A"), ws, &repo, &mut meta, apply_options())?; + but_workspace::branch::apply(r("refs/heads/A"), &ws, &repo, &mut meta, apply_options())?; assert_eq!(out.status, OutcomeStatus::Applied); // applying A rebuilds the workspace around C and A snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" * 53c254d (B) B -| * dbb9910 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +| * 991a38a (HEAD -> gitbutler/workspace) GitButler Workspace Commit | |\ | | * 863775d (C) add C | |/ @@ -2369,7 +2377,7 @@ fn apply_already_applied_branch_from_adhoc_checkout_excludes_other_applied_stack standard_traversal_options(), )?; let out = - but_workspace::branch::apply(r("refs/heads/C"), ws, &repo, &mut meta, apply_options())?; + but_workspace::branch::apply(r("refs/heads/C"), &ws, &repo, &mut meta, apply_options())?; assert_eq!(out.status, OutcomeStatus::Applied); assert_eq!( @@ -2444,7 +2452,7 @@ fn new_workspace_exists_elsewhere_and_to_be_applied_branch_exists_there() -> any // Put "A" into the workspace, hence we want "A" and "B" in it. let out = - but_workspace::branch::apply(r("refs/heads/A"), ws, &repo, &mut meta, apply_options())?; + but_workspace::branch::apply(r("refs/heads/A"), &ws, &repo, &mut meta, apply_options())?; snapbox::assert_data_eq!( out.to_debug(), snapbox::str![[r#" @@ -2457,7 +2465,7 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; // This apply brings both branches into the existing workspace, and it's where HEAD points to. snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -2568,12 +2576,12 @@ mod unapply_checked_out { Category::LocalBranch .to_full_name(branch_to_apply)? .as_ref(), - ws, + &ws, &repo, &mut meta, apply_options(), )?; - ws = out.workspace; + ws = out.display_workspace()?; } { // C and B are real stacks in the managed workspace @@ -2646,7 +2654,7 @@ Outcome { // the returned workspace is projected from the managed workspace ref snapbox::assert_data_eq!( - graph_workspace(&out.workspace).to_string(), + graph_workspace(&out.display_workspace()?).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! โ””โ”€โ”€ โ‰ก๐Ÿ“™:A {1} @@ -2693,7 +2701,7 @@ Outcome { ); // the returned workspace is projected from the remaining stack snapbox::assert_data_eq!( - graph_workspace(&out.workspace).to_string(), + graph_workspace(&out.display_workspace()?).to_string(), snapbox::str![[r#" โŒ‚:A[๐ŸŒณ] <> โœ“! โ””โ”€โ”€ โ‰ก๐Ÿ“™:A[๐ŸŒณ] {1} @@ -2764,7 +2772,7 @@ Outcome { ); // the returned workspace is projected from the managed workspace ref snapbox::assert_data_eq!( - graph_workspace(&out.workspace).to_string(), + graph_workspace(&out.display_workspace()?).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 @@ -2806,7 +2814,7 @@ Outcome { // the returned workspace preserves the unrelated checked-out entrypoint snapbox::assert_data_eq!( - graph_workspace(&out.workspace).to_string(), + graph_workspace(&out.display_workspace()?).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“! โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:B[๐ŸŒณ] {2} @@ -2853,7 +2861,7 @@ Outcome { // the returned workspace is projected from the managed workspace ref snapbox::assert_data_eq!( - graph_workspace(&out.workspace).to_string(), + graph_workspace(&out.display_workspace()?).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! โ””โ”€โ”€ โ‰ก๐Ÿ“™:C {43} @@ -2905,7 +2913,7 @@ Outcome { // the returned workspace preserves the unrelated checked-out entrypoint snapbox::assert_data_eq!( - graph_workspace(&out.workspace).to_string(), + graph_workspace(&out.display_workspace()?).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace <> โœ“! โ””โ”€โ”€ โ‰ก๐Ÿ‘‰๐Ÿ“™:B[๐ŸŒณ] {42} @@ -2968,7 +2976,7 @@ fn apply_multiple_without_target_or_metadata_or_base() -> anyhow::Result<()> { ); let out = - but_workspace::branch::apply(r("refs/heads/A"), ws, &repo, &mut meta, apply_options())?; + but_workspace::branch::apply(r("refs/heads/A"), &ws, &repo, &mut meta, apply_options())?; snapbox::assert_data_eq!( out.to_debug(), snapbox::str![[r#" @@ -2981,7 +2989,7 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -2999,7 +3007,7 @@ Outcome { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* d87b903 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* aae2077 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ | * bf53300 (A) add A * | b1540e5 (main) M @@ -3013,7 +3021,7 @@ Outcome { ); let branch_b_rt = r("refs/remotes/origin/B"); - let out = but_workspace::branch::apply(branch_b_rt, ws, &repo, &mut meta, apply_options())?; + let out = but_workspace::branch::apply(branch_b_rt, &ws, &repo, &mut meta, apply_options())?; snapbox::assert_data_eq!( out.to_debug(), snapbox::str![[r#" @@ -3026,7 +3034,7 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -3047,7 +3055,7 @@ Outcome { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -*-. 7bcf528 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +*-. 493c88c (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ \ | | * 0e391b2 (origin/B, B) add B | * | bf53300 (A) add A @@ -3092,7 +3100,7 @@ Outcome { "#]] ); - let ws = out.workspace.into_owned(); + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -3133,7 +3141,7 @@ Outcome { "#]] ); - let ws = out.workspace.into_owned(); + let ws = out.display_workspace()?; // By default, we keep the workspace, and `main` is in there as a target is missing snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -3190,16 +3198,16 @@ fn unapply_dirty_worktree_abort_keeps_refs_and_metadata() -> anyhow::Result<()> "#]] ); let out = - but_workspace::branch::apply(r("refs/heads/A"), ws, &repo, &mut meta, apply_options())?; - let ws = out.workspace; + but_workspace::branch::apply(r("refs/heads/A"), &ws, &repo, &mut meta, apply_options())?; + let ws = out.display_workspace()?; let out = but_workspace::branch::apply( r("refs/remotes/origin/B"), - ws, + &ws, &repo, &mut meta, apply_options(), )?; - let ws = out.workspace; + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -3296,7 +3304,7 @@ fn apply_repairs_stale_outside_metadata_for_reachable_branch() -> anyhow::Result meta.set_workspace(&ws_md)?; let out = - but_workspace::branch::apply(r("refs/heads/B"), ws, &repo, &mut meta, apply_options())?; + but_workspace::branch::apply(r("refs/heads/B"), &ws, &repo, &mut meta, apply_options())?; assert_eq!( out.status, OutcomeStatus::Applied, @@ -3352,7 +3360,7 @@ fn apply_multiple_segments_of_stack_in_order_merge_if_needed() -> anyhow::Result // Add another stack to be sure we correctly handle the removal of existing stacks later (i.e. don't get the index wrong) let out = but_workspace::branch::apply( r("refs/heads/unrelated"), - ws, + &ws, &repo, &mut meta, apply_options(), @@ -3374,7 +3382,7 @@ Outcome { snapbox::str![[r#" * f1889e7 (A2) add A2 * 7de99e1 (A1) add A1 -| * 6848743 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +| * e84c641 (HEAD -> gitbutler/workspace) GitButler Workspace Commit | * 53ad0c2 (unrelated) add U1 |/ * 3183e43 (origin/main, main) M1 @@ -3382,10 +3390,10 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; let out = - but_workspace::branch::apply(r("refs/heads/A1"), ws, &repo, &mut meta, apply_options())?; + but_workspace::branch::apply(r("refs/heads/A1"), &ws, &repo, &mut meta, apply_options())?; snapbox::assert_data_eq!( out.to_debug(), snapbox::str![[r#" @@ -3398,7 +3406,7 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -3415,7 +3423,7 @@ Outcome { let out = but_workspace::branch::apply( r("refs/heads/A2"), - ws, + &ws, &repo, &mut meta, but_workspace::branch::apply::Options { @@ -3436,7 +3444,7 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -3518,7 +3526,7 @@ Outcome { "#]] ); - let ws = out.workspace.into_owned(); + let ws = out.display_workspace()?; // A2 is removed, along with A1 snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -3549,7 +3557,7 @@ Outcome { "#]] ); - let ws = out.workspace.into_owned(); + let ws = out.workspace.unwrap_or(ws); // nothing changed, this was a no-op snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -3582,7 +3590,7 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -3740,7 +3748,7 @@ fn detached_head_journey() -> anyhow::Result<()> { ); let out = - but_workspace::branch::apply(r("refs/heads/C"), ws, &repo, &mut meta, apply_options())?; + but_workspace::branch::apply(r("refs/heads/C"), &ws, &repo, &mut meta, apply_options())?; snapbox::assert_data_eq!( out.to_debug(), @@ -3754,7 +3762,7 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; // the actual HEAD is ignored, and we only see C (instead of C + HEAD-ref) snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -3782,7 +3790,7 @@ Outcome { ); let out = - but_workspace::branch::apply(r("refs/heads/B"), ws, &repo, &mut meta, apply_options())?; + but_workspace::branch::apply(r("refs/heads/B"), &ws, &repo, &mut meta, apply_options())?; snapbox::assert_data_eq!( out.to_debug(), @@ -3799,7 +3807,7 @@ Outcome { visualize_commit_graph_all(&repo)?, snapbox::str![[r#" * 49d4b34 (A) A1 -| * fdec130 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +| * 19ec610 (HEAD -> gitbutler/workspace) GitButler Workspace Commit | |\ | | * f57c528 (B) B1 | |/ @@ -3812,7 +3820,7 @@ Outcome { .raw() ); - let ws = out.workspace; + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -3829,7 +3837,7 @@ Outcome { let out = but_workspace::branch::apply( r("refs/heads/A"), - ws, + &ws, &repo, &mut meta, but_workspace::branch::apply::Options { @@ -3850,7 +3858,7 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -3871,7 +3879,7 @@ Outcome { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -*-. 951ff29 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +*-. 3deecc0 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ \ | | * f57c528 (B) B1 | * | aaa195b (C) C1 @@ -3901,7 +3909,7 @@ Outcome { "#]] ); - let ws = out.workspace.into_owned(); + let ws = out.display_workspace()?; // A was removed snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -3934,7 +3942,7 @@ Outcome { "#]] ); - let ws = out.workspace.into_owned(); + let ws = out.display_workspace()?; // B was removed snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -3965,7 +3973,7 @@ Outcome { "#]] ); - let ws = out.workspace.into_owned(); + let ws = out.display_workspace()?; // the workspace reference remains checked out and empty because no non-stack fallback exists snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -4003,17 +4011,17 @@ fn unapply_workspace_ref_without_target_checks_out_named_stack() -> anyhow::Resu Category::LocalBranch .to_full_name(branch_to_apply)? .as_ref(), - ws, + &ws, &repo, &mut meta, apply_options(), )?; - ws = out.workspace; + ws = out.display_workspace()?; } let out = but_workspace::branch::apply( r("refs/heads/A"), - ws, + &ws, &repo, &mut meta, but_workspace::branch::apply::Options { @@ -4021,7 +4029,7 @@ fn unapply_workspace_ref_without_target_checks_out_named_stack() -> anyhow::Resu ..apply_options() }, )?; - let ws = out.workspace; + let ws = out.display_workspace()?; // the workspace has named stacks but no target ref snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -4075,7 +4083,7 @@ Outcome { "#]] ); - let ws = out.workspace.into_owned(); + let ws = out.display_workspace()?; // the projection shows the checked-out named stack snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -4143,12 +4151,12 @@ fn unapply_workspace_ref_refuses_conflicted_named_stack_checkout() -> anyhow::Re )?; let out = but_workspace::branch::apply( r("refs/heads/tip-conflicted"), - ws, + &ws, &repo, &mut meta, apply_options(), )?; - let ws = out.workspace; + let ws = out.display_workspace()?; // the target-less workspace can contain a conflicted named stack snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -4179,7 +4187,7 @@ fn unapply_workspace_ref_refuses_conflicted_named_stack_checkout() -> anyhow::Re snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* 8a0bbba (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 2264576 (HEAD -> gitbutler/workspace) GitButler Workspace Commit * 8450331 (tag: conflicted, tip-conflicted) GitButler WIP Commit * a047f81 (tag: normal, main) init @@ -4220,7 +4228,7 @@ fn apply_two_ambiguous_stacks_with_target_with_dependent_branch() -> anyhow::Res // Apply the dependent branch, to bring in only the dependent branch let out = - but_workspace::branch::apply(r("refs/heads/E"), ws, &repo, &mut meta, apply_options())?; + but_workspace::branch::apply(r("refs/heads/E"), &ws, &repo, &mut meta, apply_options())?; snapbox::assert_data_eq!( out.to_debug(), snapbox::str![[r#" @@ -4233,7 +4241,7 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -4248,8 +4256,8 @@ Outcome { // Apply the former tip of the stack, to create a new stack. Note how it won't double-list the // other stack. let out = - but_workspace::branch::apply(r("refs/heads/C"), ws, &repo, &mut meta, apply_options())?; - let ws = out.workspace; + but_workspace::branch::apply(r("refs/heads/C"), &ws, &repo, &mut meta, apply_options())?; + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -4267,7 +4275,7 @@ Outcome { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* 78f3659 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* e9850d8 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ | * f084d61 (C, B, A) A2 |/ @@ -4283,8 +4291,8 @@ Outcome { // Accepting this behaviour for now as it's quite rare to have such ambiguity, even though I'd love if one day // for this to just work as people might intuitively want, even if that means the same commit is used multiple times. let out = - but_workspace::branch::apply(r("refs/heads/B"), ws, &repo, &mut meta, apply_options())?; - let ws = out.workspace; + but_workspace::branch::apply(r("refs/heads/B"), &ws, &repo, &mut meta, apply_options())?; + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -4303,9 +4311,9 @@ Outcome { // This is what happens because we notice that C can't be applied as independent stack due to the graph algorithm, // and then it tries it a dependent stack, which should always work. let out = - but_workspace::branch::apply(r("refs/heads/C"), ws, &repo, &mut meta, apply_options()) + but_workspace::branch::apply(r("refs/heads/C"), &ws, &repo, &mut meta, apply_options()) .unwrap(); - let ws = out.workspace; + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -4353,7 +4361,7 @@ fn apply_two_ambiguous_stacks_with_target() -> anyhow::Result<()> { // Apply `A` first. let out = - but_workspace::branch::apply(r("refs/heads/A"), ws, &repo, &mut meta, apply_options())?; + but_workspace::branch::apply(r("refs/heads/A"), &ws, &repo, &mut meta, apply_options())?; snapbox::assert_data_eq!( out.to_debug(), snapbox::str![[r#" @@ -4365,7 +4373,7 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -4380,7 +4388,7 @@ Outcome { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* 773e030 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* f6ef303 (HEAD -> gitbutler/workspace) GitButler Workspace Commit * f084d61 (C, B, A) A2 * 7076dee (E, D) A1 * 85efbe4 (origin/main, main) M @@ -4390,7 +4398,7 @@ Outcome { // Apply `B` - the only sane way is to make it its own stack, but allow it to diverge. let out = - but_workspace::branch::apply(r("refs/heads/B"), ws, &repo, &mut meta, apply_options()) + but_workspace::branch::apply(r("refs/heads/B"), &ws, &repo, &mut meta, apply_options()) .expect("apply actually works"); snapbox::assert_data_eq!( out.to_debug(), @@ -4404,7 +4412,7 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -4420,7 +4428,7 @@ Outcome { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* b390237 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 7246aa3 (HEAD -> gitbutler/workspace) GitButler Workspace Commit * f084d61 (C, B, A) A2 * 7076dee (E, D) A1 * 85efbe4 (origin/main, main) M @@ -4430,7 +4438,7 @@ Outcome { // What follows is a bit wonky, but for now is here to document what happens in a complex scenario. let out = - but_workspace::branch::apply(r("refs/heads/C"), ws, &repo, &mut meta, apply_options()) + but_workspace::branch::apply(r("refs/heads/C"), &ws, &repo, &mut meta, apply_options()) .expect("apply actually works"); // applying C succeeds and updates the workspace metadata snapbox::assert_data_eq!( @@ -4445,7 +4453,7 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; // C is recorded as another segment at the same tip in the B/A stack snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -4464,7 +4472,7 @@ Outcome { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* b390237 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 7246aa3 (HEAD -> gitbutler/workspace) GitButler Workspace Commit * f084d61 (C, B, A) A2 * 7076dee (E, D) A1 * 85efbe4 (origin/main, main) M @@ -4473,7 +4481,7 @@ Outcome { ); let out = - but_workspace::branch::apply(r("refs/heads/D"), ws, &repo, &mut meta, apply_options()) + but_workspace::branch::apply(r("refs/heads/D"), &ws, &repo, &mut meta, apply_options()) .expect("apply actually works"); // applying D succeeds and updates the workspace metadata snapbox::assert_data_eq!( @@ -4488,7 +4496,7 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; // D becomes a lower segment in the same projected stack, leaving E as the remaining alternate ref at that commit snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -4508,7 +4516,7 @@ Outcome { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* b390237 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 7246aa3 (HEAD -> gitbutler/workspace) GitButler Workspace Commit * f084d61 (C, B, A) A2 * 7076dee (E, D) A1 * 85efbe4 (origin/main, main) M @@ -4517,7 +4525,7 @@ Outcome { ); let out = - but_workspace::branch::apply(r("refs/heads/E"), ws, &repo, &mut meta, apply_options()) + but_workspace::branch::apply(r("refs/heads/E"), &ws, &repo, &mut meta, apply_options()) .expect("apply actually works"); // applying E forces the lower same-commit branch pair into its own stack snapbox::assert_data_eq!( @@ -4532,7 +4540,7 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; // applying all ambiguous dependent branches ends with B/C/A and E/D split into two stacks snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -4554,7 +4562,7 @@ Outcome { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* 2c125a1 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 139346d (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ * | f084d61 (C, B, A) A2 |/ @@ -4589,7 +4597,7 @@ Outcome { "legacy mode should rebuild the workspace commit after removing E/D" ); - let ws = out.workspace.into_owned(); + let ws = out.display_workspace()?; // after unapplying E, legacy mode keeps a workspace commit snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -4609,7 +4617,7 @@ Outcome { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* b390237 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* f1a206b (HEAD -> gitbutler/workspace) GitButler Workspace Commit * f084d61 (C, B, A) A2 * 7076dee (E, D) A1 * 85efbe4 (origin/main, main) M @@ -4637,7 +4645,7 @@ Outcome { "#]] ); - let ws = out.workspace.into_owned(); + let ws = out.display_workspace()?; // the B/C/A stack stays applied after the D no-op snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -4657,7 +4665,7 @@ Outcome { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* b390237 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* f1a206b (HEAD -> gitbutler/workspace) GitButler Workspace Commit * f084d61 (C, B, A) A2 * 7076dee (E, D) A1 * 85efbe4 (origin/main, main) M @@ -4685,7 +4693,7 @@ Outcome { "#]] ); - let ws = out.workspace.into_owned(); + let ws = out.display_workspace()?; // after unapplying C, B/A/E remains applied with D only as an ambiguous ref on E's tip snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -4693,10 +4701,9 @@ Outcome { ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {41} โ”œโ”€โ”€ ๐Ÿ“™:B - โ”œโ”€โ”€ ๐Ÿ“™:A - โ”‚ โ””โ”€โ”€ ยทf084d61 (๐Ÿ˜๏ธ) - โ””โ”€โ”€ ๐Ÿ“™:E - โ””โ”€โ”€ ยท7076dee (๐Ÿ˜๏ธ) โ–บD + โ””โ”€โ”€ ๐Ÿ“™:A + โ”œโ”€โ”€ ยทf084d61 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ยท7076dee (๐Ÿ˜๏ธ) โ–บD, โ–บE "#]] ); @@ -4704,7 +4711,7 @@ Outcome { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* b390237 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* f1a206b (HEAD -> gitbutler/workspace) GitButler Workspace Commit * f084d61 (C, B, A) A2 * 7076dee (E, D) A1 * 85efbe4 (origin/main, main) M @@ -4732,7 +4739,7 @@ Outcome { "#]] ); - let ws = out.workspace.into_owned(); + let ws = out.display_workspace()?; // after unapplying B, no applied stacks remain snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -4747,7 +4754,7 @@ Outcome { snapbox::str![[r#" * f084d61 (C, B, A) A2 * 7076dee (E, D) A1 -| * bde8ed6 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +| * 77cd466 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |/ * 85efbe4 (origin/main, main) M @@ -4774,7 +4781,7 @@ Outcome { "#]] ); - let ws = out.workspace.into_owned(); + let ws = out.workspace.unwrap_or(ws); // the workspace stays empty after the A no-op snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -4789,7 +4796,7 @@ Outcome { snapbox::str![[r#" * f084d61 (C, B, A) A2 * 7076dee (E, D) A1 -| * bde8ed6 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +| * 77cd466 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |/ * 85efbe4 (origin/main, main) M @@ -4818,7 +4825,7 @@ Outcome { "#]] ); - let ws = out.workspace.into_owned(); + let ws = out.display_workspace()?; // the projection is the checked-out target branch snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -4900,13 +4907,13 @@ fn apply_with_conflicts_shows_exact_conflict_info() -> anyhow::Result<()> { Category::LocalBranch .to_full_name(branch_to_apply)? .as_ref(), - ws, + &ws, &repo, &mut meta, apply_options(), ) .unwrap_or_else(|err| panic!("{branch_to_apply}: {err}")); - ws = out.workspace; + ws = out.display_workspace()?; } snapbox::assert_data_eq!( @@ -4938,7 +4945,7 @@ fn apply_with_conflicts_shows_exact_conflict_info() -> anyhow::Result<()> { snapbox::str![[r#" * 4bbb93c (conflict-hero) add conflicting-F2 * 98519e9 add conflicting-F1 -| *-----. e13e11a (HEAD -> gitbutler/workspace) GitButler Workspace Commit +| *-----. 9c93ced (HEAD -> gitbutler/workspace) GitButler Workspace Commit |/|\ \ \ \ | | | | | * 34c4591 (clean-C) add C | |_|_|_|/ @@ -4962,7 +4969,7 @@ fn apply_with_conflicts_shows_exact_conflict_info() -> anyhow::Result<()> { let out = but_workspace::branch::apply( r("refs/heads/conflict-hero"), - ws, + &ws, &repo, &mut meta, apply_options(), @@ -4987,7 +4994,7 @@ Outcome { } "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; // By default, it fails and just reports the conflicting stacks, so it's the same as it was before. snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -5025,7 +5032,7 @@ Outcome { let out = but_workspace::branch::apply( r("refs/heads/conflict-hero"), - ws, + &ws, &repo, &mut meta, but_workspace::branch::apply::Options { @@ -5056,7 +5063,7 @@ Outcome { ); // Now the other stacks are unapplied. - let ws = out.workspace; + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -5085,7 +5092,7 @@ Outcome { * bf09eae (conflict-F1) add F1 | * f2ce66d (conflict-F2) add F2 |/ -| *---. c51f37c (HEAD -> gitbutler/workspace) GitButler Workspace Commit +| *---. aefb230 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |/|\ \ \ | | | | * 4bbb93c (conflict-hero) add conflicting-F2 | | | | * 98519e9 add conflicting-F1 @@ -5222,8 +5229,8 @@ fn conflicting_apply_reports_no_applied_branches_and_names_conflicting_stacks() ); let out = - but_workspace::branch::apply(r("refs/heads/A"), ws, &repo, &mut meta, apply_options())?; - let ws = out.workspace; + but_workspace::branch::apply(r("refs/heads/A"), &ws, &repo, &mut meta, apply_options())?; + let ws = out.display_workspace()?; // A is applied before trying the conflicting sibling branch snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -5241,7 +5248,7 @@ fn conflicting_apply_reports_no_applied_branches_and_names_conflicting_stacks() snapbox::str![[r#" * 543911c (add-A-too) add a different A * b1540e5 (main) M -| * 9f5b797 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +| * 0c1287d (HEAD -> gitbutler/workspace) GitButler Workspace Commit | * bf53300 (A) add A |/ | * 0e391b2 (origin/B) add B @@ -5253,7 +5260,7 @@ fn conflicting_apply_reports_no_applied_branches_and_names_conflicting_stacks() let out = but_workspace::branch::apply( r("refs/heads/add-A-too"), - ws, + &ws, &repo, &mut meta, apply_options(), @@ -5340,7 +5347,7 @@ fn unapply_with_workspace_merge_conflicts_always_works_as_conflicts_do_not_repea Category::LocalBranch .to_full_name(branch_to_apply)? .as_ref(), - ws, + &ws, &repo, &mut meta, but_workspace::branch::apply::Options { @@ -5349,7 +5356,7 @@ fn unapply_with_workspace_merge_conflicts_always_works_as_conflicts_do_not_repea ..apply_options() }, )?; - ws = out.workspace; + ws = out.display_workspace()?; } // all branches are applied snapbox::assert_data_eq!( @@ -5385,7 +5392,7 @@ fn unapply_with_workspace_merge_conflicts_always_works_as_conflicts_do_not_repea &mut meta, unapply_options_with(WorkspaceDisposition::KeepWorkspaceReference), )?; - ws = out.workspace.into_owned(); + ws.adopt(out.workspace); } // the workspace ref remains because the regenerated workspace still has main available @@ -5436,7 +5443,7 @@ fn auto_checkout_of_enclosing_workspace_flat() -> anyhow::Result<()> { // Apply the workspace ref itself, it's a no-op let out = but_workspace::branch::apply( r("refs/heads/gitbutler/workspace"), - ws, + &ws, &repo, &mut meta, apply_options(), @@ -5474,13 +5481,7 @@ Outcome { "#]] ); // Already applied (the HEAD points to it). - let out = but_workspace::branch::apply( - b_ref.as_ref(), - ws.clone(), - &repo, - &mut meta, - apply_options(), - )?; + let out = but_workspace::branch::apply(b_ref.as_ref(), &ws, &repo, &mut meta, apply_options())?; // no-ops aren't listing the already applied branches snapbox::assert_data_eq!( out.to_debug(), @@ -5504,7 +5505,7 @@ Outcome { // To apply A, we checkout the surrounding workspace and repair the stale metadata. let out = - but_workspace::branch::apply(r("refs/heads/A"), ws, &repo, &mut meta, apply_options())?; + but_workspace::branch::apply(r("refs/heads/A"), &ws, &repo, &mut meta, apply_options())?; assert_eq!( out.status, OutcomeStatus::Applied, @@ -5530,7 +5531,7 @@ Outcome { ); // Now the workspace ref itself is checked out. - let ws = out.workspace; + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -5577,7 +5578,7 @@ Outcome { ); let out = - but_workspace::branch::apply(r("refs/heads/A"), ws, &repo, &mut meta, apply_options())?; + but_workspace::branch::apply(r("refs/heads/A"), &ws, &repo, &mut meta, apply_options())?; // Nothing changed, the desired branch was already applied snapbox::assert_data_eq!( out.to_debug(), @@ -5611,7 +5612,7 @@ Outcome { // Apply the first branch, it must be independent. let out = - but_workspace::branch::apply(r("refs/heads/A"), ws, &repo, &mut meta, apply_options())?; + but_workspace::branch::apply(r("refs/heads/A"), &ws, &repo, &mut meta, apply_options())?; assert_eq!( out.status, OutcomeStatus::Applied, @@ -5628,7 +5629,7 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -5641,7 +5642,7 @@ Outcome { // Apply the first branch, it must be independent. let out = - but_workspace::branch::apply(r("refs/heads/B"), ws, &repo, &mut meta, apply_options())?; + but_workspace::branch::apply(r("refs/heads/B"), &ws, &repo, &mut meta, apply_options())?; snapbox::assert_data_eq!( out.to_debug(), snapbox::str![[r#" @@ -5655,7 +5656,7 @@ Outcome { ); // B is added as independent stack snapbox::assert_data_eq!( - graph_workspace(&out.workspace).to_string(), + graph_workspace(&out.display_workspace()?).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“! on e5d0542 โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on e5d0542 {41} @@ -5687,7 +5688,7 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; let out = but_workspace::branch::unapply( r("refs/heads/A"), &ws, @@ -5706,7 +5707,7 @@ Outcome { "#]] ); - let ws = out.workspace.into_owned(); + let ws = out.display_workspace()?; // the workspace is empty again snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -5742,7 +5743,7 @@ Outcome { "#]] ); - let ws = out.workspace.into_owned(); + let ws = out.display_workspace()?; // the workspace is empty again snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -5802,7 +5803,7 @@ fn auto_checkout_of_enclosing_workspace_with_commits() -> anyhow::Result<()> { // Apply the workspace ref itself, it's a no-op let ws_ref = r("refs/heads/gitbutler/workspace"); - let out = but_workspace::branch::apply(ws_ref, ws, &repo, &mut meta, apply_options())?; + let out = but_workspace::branch::apply(ws_ref, &ws, &repo, &mut meta, apply_options())?; // the workspace ref itself counts as no-op as well snapbox::assert_data_eq!( out.to_debug(), @@ -5839,13 +5840,7 @@ Outcome { ); // Already applied (the HEAD points to it, it literally IS the workspace). - let out = but_workspace::branch::apply( - b_ref.as_ref(), - ws.clone(), - &repo, - &mut meta, - apply_options(), - )?; + let out = but_workspace::branch::apply(b_ref.as_ref(), &ws, &repo, &mut meta, apply_options())?; // already applied branches are a no-op, even when a stack segment is checked out snapbox::assert_data_eq!( out.to_debug(), @@ -5859,8 +5854,8 @@ Outcome { "#]] ); - let err = but_workspace::branch::apply(ws_ref, ws.clone(), &repo, &mut meta, apply_options()) - .unwrap_err(); + let err = + but_workspace::branch::apply(ws_ref, &ws, &repo, &mut meta, apply_options()).unwrap_err(); assert_eq!( err.to_string(), "Refusing to apply a reference that already is a workspace: 'gitbutler/workspace'", @@ -5871,7 +5866,7 @@ Outcome { // To apply, we just checkout the surrounding workspace. let b_tip_before_apply = id_by_rev(&repo, "B"); let out = - but_workspace::branch::apply(r("refs/heads/A"), ws, &repo, &mut meta, apply_options())?; + but_workspace::branch::apply(r("refs/heads/A"), &ws, &repo, &mut meta, apply_options())?; assert_eq!( out.status, OutcomeStatus::Applied, @@ -5894,7 +5889,7 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -5947,7 +5942,7 @@ fn apply_nonexisting_branch_failure() -> anyhow::Result<()> { let err = but_workspace::branch::apply( r("refs/heads/does-not-exist"), - ws, + &ws, &repo, &mut *meta, apply_options(), @@ -6054,7 +6049,7 @@ fn unborn_apply_needs_base() -> anyhow::Result<()> { // Idempotency in ad-hoc workspace let out = but_workspace::branch::apply( r("refs/heads/main"), - ws.clone(), + &ws, &repo, &mut *meta, apply_options(), @@ -6076,7 +6071,7 @@ Outcome { // but since remote is transformed into a local tracking branch, it's a noop. let out = but_workspace::branch::apply( r("refs/remotes/orphan/main"), - ws, + &ws, &repo, &mut *meta, apply_options(), @@ -6094,7 +6089,7 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -6164,3 +6159,155 @@ mod utils { } } use utils::{standard_traversal_options, standard_traversal_options_with_extra_target}; + +/// A shared parent segment legitimately displays under EVERY stack that contains it โ€” +/// unapply's metadata reconciliation must not trip over the duplicate name. +#[test] +fn unapply_with_shared_segment_across_stacks() -> anyhow::Result<()> { + let (_tmp, ws, repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph( + "ws-ref-ws-commit-two-stacks-shared-parent", + |meta| { + add_stack_with_segments(meta, 1, "B-on-A", StackState::InWorkspace, &[]); + add_stack_with_segments(meta, 2, "C-on-A", StackState::InWorkspace, &[]); + }, + )?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B-on-A on 85efbe4 {1} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B-on-A +โ”‚ โ”‚ โ””โ”€โ”€ ยท654819c (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ :A +โ”‚ โ””โ”€โ”€ ยท7076dee (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:C-on-A on 85efbe4 {2} + โ”œโ”€โ”€ ๐Ÿ“™:C-on-A + โ”‚ โ””โ”€โ”€ ยท76d6426 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :A + โ””โ”€โ”€ ยท7076dee (๐Ÿ˜๏ธ) + +"#]] + ); + + let out = but_workspace::branch::unapply( + r("refs/heads/C-on-A"), + &ws, + &repo, + &mut meta, + unapply_options(), + ) + .expect("the duplicate display mention of the shared segment must not fail reconciliation"); + + // C-on-A is gone; B-on-A keeps the shared parent below it. + snapbox::assert_data_eq!( + graph_workspace(&out.display_workspace()?).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธโš ๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B-on-A on 85efbe4 {1} + โ”œโ”€โ”€ ๐Ÿ“™:B-on-A + โ”‚ โ””โ”€โ”€ ยท654819c (๐Ÿ˜๏ธ) + โ””โ”€โ”€ :A + โ””โ”€โ”€ ยท7076dee (๐Ÿ˜๏ธ) + +"#]] + ); + let ws_md = meta.workspace(r("refs/heads/gitbutler/workspace"))?; + assert_eq!( + ws_md + .stacks + .iter() + .flat_map(|stack| stack.branches.iter()) + .filter(|branch| branch.ref_name.as_ref().as_bstr() == "refs/heads/A") + .count(), + 0, + "the unlisted shared parent stays out of metadata โ€” display sharing is not membership" + ); + Ok(()) +} + +/// KEYSTONE for editor-based unapply: removing the +/// wsโ†’leg edge selected BY REFERENCE and materializing rebuilds the merge minus +/// exactly that leg โ€” objects, safe checkout and ref edits in that order. +#[test] +fn editor_leg_removal_rebuilds_merge() -> anyhow::Result<()> { + let (_tmp, mut ws, repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph( + "ws-ref-ws-commit-two-stacks-shared-parent", + |meta| { + add_stack_with_segments(meta, 1, "B-on-A", StackState::InWorkspace, &[]); + add_stack_with_segments(meta, 2, "C-on-A", StackState::InWorkspace, &[]); + }, + )?; + let ws_commit = ws.tip_commit_id().expect("managed ws commit"); + + let mut editor = but_rebase::graph_rebase::Editor::create( + ws.commit_graph(), + ws.project_meta(), + &mut meta, + &repo, + )?; + let ws_pick = editor.select_commit(ws_commit)?; + let leg_ref = editor.select_reference("refs/heads/C-on-A".try_into()?)?; + let removed = editor.detach(ws_pick, leg_ref)?; + assert_eq!( + removed.len(), + 1, + "exactly the subject's leg edge is removed" + ); + + let materialized = editor.rebase()?.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; + let project_meta = ws.project_meta().clone(); + ws.refresh_from_head(&repo, &meta, project_meta)?; + + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 76d6426 (C-on-A) C1 +| * c94e1b5 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +| * 654819c (B-on-A) B1 +|/ +* 7076dee (A) A1 +* 85efbe4 (origin/main, main) M + +"#]] + ); + Ok(()) +} + +/// Unapplying X performs ONE targeted metadata edit: X's stack leaves, and no other +/// stack's declaration changes in any way โ€” no minted ids, no membership moves, no +/// order changes (the old projection-reconciliation rewrote unrelated stacks). +#[test] +fn unapply_leaves_unrelated_metadata_untouched() -> anyhow::Result<()> { + let (_tmp, ws, repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph( + "ws-ref-ws-commit-single-stack-double-stack", + |meta| { + add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); + add_stack_with_segments(meta, 2, "C", StackState::InWorkspace, &["B"]); + }, + )?; + let ws_ref = r("refs/heads/gitbutler/workspace"); + let before: Vec<_> = meta.workspace(ws_ref)?.stacks.clone(); + + but_workspace::branch::unapply(r("refs/heads/A"), &ws, &repo, &mut meta, unapply_options())?; + + let after: Vec<_> = meta.workspace(ws_ref)?.stacks.clone(); + let expected: Vec<_> = before + .iter() + .filter(|stack| { + stack + .branches + .iter() + .all(|branch| branch.ref_name.as_ref().as_bstr() != "refs/heads/A") + }) + .cloned() + .collect(); + assert_eq!( + after, expected, + "only A's stack left the declaration; everything else is byte-identical" + ); + Ok(()) +} diff --git a/crates/but-workspace/tests/workspace/branch/create_reference.rs b/crates/but-workspace/tests/workspace/branch/create_reference.rs index f1c5d5f946d..fee9e7dbfb4 100644 --- a/crates/but-workspace/tests/workspace/branch/create_reference.rs +++ b/crates/but-workspace/tests/workspace/branch/create_reference.rs @@ -180,7 +180,8 @@ Single commit, target, no ws commit, but ws-reference stack_id_for_name, None, ) - .expect("it updates the workspace metadata legitimate the new ref at base"); + .expect("it updates the workspace metadata legitimate the new ref at base") + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -206,7 +207,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -219,7 +221,7 @@ Single commit, target, no ws commit, but ws-reference "#]] ); - // Idempotency + // Idempotency: `None` = nothing changed, the input workspace stays current. let ws = but_workspace::branch::create_reference( b_ref, None, /* anchor */ @@ -228,7 +230,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .unwrap_or(ws); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -253,7 +256,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -279,7 +283,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -368,7 +373,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); // It handles this special case, by creating the necessary workspace metadata // if for some reason (like manual building) it's not set. snapbox::assert_data_eq!( @@ -396,7 +402,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -425,7 +432,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); // Note how 'Above' *a commit* means directly above, not on top of everything. // And as there are now two references on one commit, and one has metadata, the other one doesn't, @@ -457,7 +465,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); // And 'A' is back, with the desired order correctly restored. snapbox::assert_data_eq!( @@ -488,7 +497,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); // *Above a segment means what one would expect though. snapbox::assert_data_eq!( @@ -519,7 +529,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -550,19 +561,20 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ””โ”€โ”€ โ‰ก๐Ÿ“™:above-A on bce0c5e {4cf} +โ””โ”€โ”€ โ‰ก๐Ÿ“™:above-A-commit on bce0c5e {4cf} + โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit + โ”œโ”€โ”€ ๐Ÿ“™:below-A โ”œโ”€โ”€ ๐Ÿ“™:above-A โ”œโ”€โ”€ ๐Ÿ“™:A - โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit โ”‚ โ””โ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) โ”œโ”€โ”€ ๐Ÿ“™:below-A-commit โ”œโ”€โ”€ ๐Ÿ“™:above-bottom - โ”œโ”€โ”€ ๐Ÿ“™:below-A โ”‚ โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) โ””โ”€โ”€ ๐Ÿ“™:bottom @@ -579,19 +591,20 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:above-A on bce0c5e {4cf} +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:above-A-commit on bce0c5e {4cf} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit โ”‚ โ”‚ โ””โ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A-commit โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-bottom -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A โ”‚ โ”‚ โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ๐Ÿ“™:bottom โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on bce0c5e {42} @@ -613,19 +626,20 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:above-A on bce0c5e {4cf} +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:above-A-commit on bce0c5e {4cf} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit โ”‚ โ”‚ โ””โ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A-commit โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-bottom -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A โ”‚ โ”‚ โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ๐Ÿ“™:bottom โ””โ”€โ”€ โ‰ก๐Ÿ“™:above-B on bce0c5e {42} @@ -650,19 +664,20 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:above-A on bce0c5e {4cf} +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:above-A-commit on bce0c5e {4cf} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit โ”‚ โ”‚ โ””โ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A-commit โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-bottom -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A โ”‚ โ”‚ โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ๐Ÿ“™:bottom โ””โ”€โ”€ โ‰ก๐Ÿ“™:above-B on bce0c5e {42} @@ -683,14 +698,14 @@ Single commit, target, no ws commit, but ws-reference graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on bce0c5e -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:above-A on bce0c5e {4cf} +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:above-A-commit on bce0c5e {4cf} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-A-commit โ”‚ โ”‚ โ””โ”€โ”€ ยท43f9472 (๐Ÿ˜๏ธ) โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A-commit โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:above-bottom -โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:below-A โ”‚ โ”‚ โ””โ”€โ”€ ยท6fdab32 (๐Ÿ˜๏ธ) โ”‚ โ””โ”€โ”€ ๐Ÿ“™:bottom โ””โ”€โ”€ โ‰ก๐Ÿ“™:above-B on bce0c5e {42} @@ -705,8 +720,8 @@ Single commit, target, no ws commit, but ws-reference visualize_commit_graph_all(&repo)?, snapbox::str![[r#" * 05240ea (HEAD -> gitbutler/workspace) GitButler Workspace Commit -* 43f9472 (above-A-commit, above-A, A) A2 -* 6fdab32 (below-A-commit, below-A, above-bottom) A1 +* 43f9472 (below-A, above-A-commit, above-A, A) A2 +* 6fdab32 (below-A-commit, above-bottom) A1 * bce0c5e (origin/main, main, bottom, below-B, above-B, B) M2 * 3183e43 M1 @@ -759,7 +774,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -785,7 +801,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); // We can create branches that would be on the base. // There are @@ -816,7 +833,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); // Note how 'Above' *a commit* means directly above, not on top of everything. snapbox::assert_data_eq!( @@ -847,7 +865,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); // *Above a segment means what one would expect though. snapbox::assert_data_eq!( @@ -880,7 +899,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -910,7 +930,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -941,7 +962,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -970,7 +992,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1004,7 +1027,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1041,7 +1065,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1148,7 +1173,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -1215,7 +1241,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1244,7 +1271,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -1309,7 +1337,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1338,7 +1367,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1366,7 +1396,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1395,7 +1426,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1424,7 +1456,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1500,7 +1533,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1524,7 +1558,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1548,7 +1583,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1631,7 +1667,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -1682,7 +1719,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -2042,7 +2080,8 @@ Single commit, target, no ws commit, but ws-reference .resolved_target_commit_id() .expect("the scenario sets a default target"); assert!( - ws.find_owner_indexes_by_commit_id(target_id).is_none(), + but_graph::workspace::find_commit_and_containers_in(&ws.display_stacks()?, target_id) + .is_none(), "the target tip must be outside the workspace for this repro" ); @@ -2057,7 +2096,8 @@ Single commit, target, no ws commit, but ws-reference &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); // `new-branch` emerges as its own standalone stack/segment, based at M1. snapbox::assert_data_eq!( @@ -2163,12 +2203,10 @@ fn errors() -> anyhow::Result<()> { ) .unwrap_err(); let err = err.to_string(); + // Reading the reconciled view, the base IS main's own commit, so anchoring below it is + // refused as "belongs to another branch" โ€” not the old display-artifact "not in workspace". assert!( - matches!( - err.as_str(), - "Cannot create reference on unborn branch" - | "Commit c166d42d4ef2e5e742d33554d03805cfb0b24d11 isn't part of the workspace" - ), + err.contains("already belongs to another branch"), "workspace base cannot be used as a below-anchor: {err}" ); assert!( @@ -2315,16 +2353,13 @@ fn errors() -> anyhow::Result<()> { ); let (a_id, _a_ref_owned) = id_at(&repo, "A"); - for (anchor, expected_err) in [ - ( - Anchor::at_segment(a_ref, Below), - "Cannot create reference on unborn branch", - ), - ( - Anchor::at_id(a_id, Below), - "Commit 89cc2d303514654e9cab2d05b9af08b420a740c1 isn't part of the workspace", - ), - ] { + // A's base (89cc2d) sits outside the workspace but IS in the reconciled view, owned by A's + // stack. Reading the view, both anchors resolve there and are refused as belonging to another + // branch โ€” the message is now CONSISTENT across at_segment and at_id (it used to differ). + let expected_err = "Branch 'does-not-matter' cannot be created: the target commit \ + (89cc2d303514654e9cab2d05b9af08b420a740c1) already belongs to another branch in the \ + workspace. Each commit can only belong to one branch at a time."; + for anchor in [Anchor::at_segment(a_ref, Below), Anchor::at_id(a_id, Below)] { let err = but_workspace::branch::create_reference( new_name, anchor.clone(), @@ -2338,7 +2373,7 @@ fn errors() -> anyhow::Result<()> { assert_eq!( err.to_string(), expected_err, - "{anchor:?}: TODO: make these error messages consistent, and one might argue that this makes it hard to create refs on such bases." + "{anchor:?}: creating a ref below A's out-of-workspace base is refused as belonging to another branch" ); assert!(meta.branch(a_ref)?.is_default(), "no data was stored"); assert_ne!( @@ -2394,7 +2429,8 @@ fn journey_with_commits() -> anyhow::Result<()> { stack_id_for_name, None, ) - .expect("this works as the branch is unique"); + .expect("this works as the branch is unique") + .expect("the workspace changed"); // We always add metadata to new branches. snapbox::assert_data_eq!( @@ -2432,7 +2468,8 @@ fn journey_with_commits() -> anyhow::Result<()> { &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -2456,7 +2493,8 @@ fn journey_with_commits() -> anyhow::Result<()> { &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -2489,7 +2527,7 @@ fn journey_with_commits() -> anyhow::Result<()> { "Branch 'another-below-main' cannot be created: the target commit (12995d783f3ac841a1774e9433ee8e4c1edac576) already belongs to another branch in the workspace. Each commit can only belong to one branch at a time." ); - // branch already exists in the workspace, all good. + // branch already exists in the workspace, all good: `None` = nothing changed. let main_ref = r("refs/heads/main"); let ws = but_workspace::branch::create_reference( main_ref, @@ -2499,7 +2537,8 @@ fn journey_with_commits() -> anyhow::Result<()> { &mut meta, stack_id_for_name, None, - )?; + )? + .unwrap_or(ws); assert!( meta.branch(main_ref)?.is_default(), @@ -2533,7 +2572,8 @@ fn journey_with_commits() -> anyhow::Result<()> { &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); assert!( !meta.branch(main_ref)?.is_default(), @@ -2592,7 +2632,8 @@ fn existing_git_ref_inside_workspace_is_adopted() -> anyhow::Result<()> { &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), @@ -2657,7 +2698,8 @@ fn journey_anon_workspace() -> anyhow::Result<()> { &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -2702,7 +2744,8 @@ fn journey_anon_workspace() -> anyhow::Result<()> { &mut meta, stack_id_for_name, None, - )?; + )? + .expect("the workspace changed"); snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" @@ -2809,7 +2852,7 @@ mod ad_hoc_at_reference { stack_id_for_name, None, )? - .into_owned()) + .expect("the workspace changed")) } /// Assert the durable tip-to-base order recorded for the chain containing `anchor`. @@ -3213,3 +3256,65 @@ mod ad_hoc_at_reference { Ok(()) } } + +/// PIN: an anchorless `create_reference` whose name already exists as a branch that names a +/// segment in the GRAPH (here: resting below the workspace base, i.e. integrated history) +/// but is NOT a workspace member. This is the divergence class between "names a segment in +/// the graph" (`RefLayout` fact) and "is in the workspace" (reconciled membership): the +/// idempotency guard must NOT treat it as already-present, or the call becomes a silent +/// no-op instead of whatever integration the operation decides on. +#[test] +fn create_reference_no_anchor_for_existing_below_base_branch_is_not_a_noop() -> anyhow::Result<()> { + let (_tmp, ws, repo, mut meta, _desc) = + crate::ref_info::with_workspace_commit::utils::named_writable_scenario_with_description_and_graph( + "single-branch-4-commits-more-branches", + |meta| { + crate::ref_info::with_workspace_commit::utils::add_stack_with_segments( + meta, + 0, + "A", + crate::ref_info::with_workspace_commit::utils::StackState::InWorkspace, + &[], + ); + }, + )?; + // M1 sits below the base (M2): in the graph, outside the workspace. + let below_base = repo.rev_parse_single("3183e43")?.detach(); + repo.reference( + "refs/heads/old-integrated", + below_base, + PreviousValue::Any, + "test: pre-existing branch below the base", + )?; + let ws = ws.redo(&repo, &meta, Default::default())?; + let name = r("refs/heads/old-integrated"); + + // The divergence exists: the layout names a segment for it, membership says no. + let layout_names_segment = ws + .commit_graph() + .layout() + .and_then(|l| l.facts_of(name)) + .is_some_and(|f| f.names_segment); + let is_member = ws.find_segment_and_stack_by_refname(name).is_some(); + assert!( + layout_names_segment, + "the below-base branch names a segment in the graph" + ); + assert!(!is_member, "but it is NOT a workspace member"); + + // And the operation must not silently no-op on it. + let outcome = but_workspace::branch::create_reference( + name, + None, + &repo, + &ws, + &mut meta, + stack_id_for_name, + None, + ); + match outcome { + Ok(None) => panic!("must not be treated as already-present (silent no-op)"), + Ok(Some(_)) | Err(_) => {} + } + Ok(()) +} diff --git a/crates/but-workspace/tests/workspace/branch/move_branch.rs b/crates/but-workspace/tests/workspace/branch/move_branch.rs index d1414137921..c6b18fb7273 100644 --- a/crates/but-workspace/tests/workspace/branch/move_branch.rs +++ b/crates/but-workspace/tests/workspace/branch/move_branch.rs @@ -1252,8 +1252,9 @@ fn move_non_empty_branch_onto_empty_branch_with_advanced_target() -> anyhow::Res snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 85efbe4 โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {2} - โ””โ”€โ”€ ๐Ÿ“™:A - โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) + โ”œโ”€โ”€ ๐Ÿ“™:A + โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ๐Ÿ“™:B "#]] ); @@ -1262,7 +1263,8 @@ fn move_non_empty_branch_onto_empty_branch_with_advanced_target() -> anyhow::Res } fn stack_display_order(ws: &but_graph::Workspace) -> Vec { - ws.stacks + ws.display_stacks() + .expect("displayable") .iter() .filter_map(|stack| stack.ref_name()) .map(|name| name.to_string()) @@ -1301,6 +1303,903 @@ fn set_workspace_metadata( Ok(()) } +/// The managed fuzzer's ws-with-empty-stack seed-0 finding: moving a commit-owning branch +/// above the empty sitting on its own tip ran the generic pick surgery โ€” a degenerate +/// self-move that disconnected the range from the workspace commit without reconnecting it, +/// expelling the subject's commits from the workspace (the ref survived on disk). The +/// crossed empty now re-anchors below the subject's unchanged range instead. +#[test] +fn repro_managed_empty_shuffle_projection_drop() -> anyhow::Result<()> { + use but_meta::VirtualBranchesTomlMetadata; + + fn stack_and_empty(meta: &mut VirtualBranchesTomlMetadata) { + add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); + add_stack_with_segments(meta, 2, "B", StackState::InWorkspace, &[]); + } + let (_tmp, mut ws, repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph("ws-with-empty-stack", stack_and_empty)?; + + let attempts = [ + ("refs/heads/A", "refs/heads/B"), + ("refs/heads/B", "refs/heads/A"), + ("refs/heads/B", "refs/heads/A"), + ("refs/heads/B", "refs/heads/A"), + ("refs/heads/A", "refs/heads/B"), + ]; + for (i, (subject, target)) in attempts.iter().enumerate() { + eprintln!("step {i}: move {subject} -> {target}"); + match managed_move_and_apply( + &repo, + &mut meta, + &mut ws, + (*subject).try_into()?, + (*target).try_into()?, + ) { + Ok(()) => eprintln!( + " ok\n--- commits ---\n{}--- workspace ---\n{}", + visualize_commit_graph_all(&repo)?, + graph_workspace(&ws) + ), + Err(e) => { + let msg = format!("{e:?}"); + eprintln!(" err: {msg}"); + assert!(!msg.contains("BUG:"), "collision at step {i}: {msg}"); + } + } + } + let projected: Vec<_> = ws + .display_stacks() + .expect("displayable") + .iter() + .flat_map(|stack| &stack.segments) + .filter_map(|seg| seg.ref_name().map(|n| n.as_bstr().to_string())) + .collect(); + assert!( + projected.contains(&"refs/heads/A".to_string()), + "A dropped from the projection: {projected:?}" + ); + Ok(()) +} + +/// Moving a commit-owning branch onto a ref with empties riding above it (found by fuzzing +/// the empties-between-owners fixture): the riders re-keyed onto the subject's tip carrying +/// their edge statements verbatim, but the workspace-commit edge was never redirected +/// through the inserted range โ€” the subject's commits silently left the workspace. The +/// redirect now always follows the group's edges, and the riders chain above the subject's +/// own ref instead of standing as a colliding twin group. +#[test] +fn repro_managed_move_onto_ref_with_riders() -> anyhow::Result<()> { + use but_meta::VirtualBranchesTomlMetadata; + + fn setup(meta: &mut VirtualBranchesTomlMetadata) { + add_stack_with_segments( + meta, + 1, + "e3", + StackState::InWorkspace, + &["e2", "B", "e1", "A"], + ); + add_stack_with_segments(meta, 2, "F", StackState::InWorkspace, &[]); + } + let (_tmp, mut ws, repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph( + "ws-ref-ws-commit-empties-between-owners", + setup, + )?; + + let attempts = [ + ("refs/heads/A", "refs/heads/e1"), + ("refs/heads/A", "refs/heads/F"), + ("refs/heads/B", "refs/heads/e2"), + ("refs/heads/e1", "refs/heads/F"), + // The finding: A (owns a commit) onto B, whose group carries e3 above it. + ("refs/heads/A", "refs/heads/B"), + ]; + for (i, (subject, target)) in attempts.iter().enumerate() { + if let Err(e) = managed_move_and_apply( + &repo, + &mut meta, + &mut ws, + (*subject).try_into()?, + (*target).try_into()?, + ) { + let msg = format!("{e:?}"); + assert!(!msg.contains("BUG:"), "collision at step {i}: {msg}"); + } + } + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 893d602 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:e3 on 893d602 {1} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:e3 +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:A +โ”‚ โ”‚ โ””โ”€โ”€ ยทcc8f743 (๐Ÿ˜๏ธ) +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ”‚ โ””โ”€โ”€ ยทf0058fc (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:e2 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:e1 on 893d602 {2} + โ”œโ”€โ”€ ๐Ÿ“™:e1 + โ””โ”€โ”€ ๐Ÿ“™:F + โ””โ”€โ”€ ยท9920891 (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +/// Moving an empty branch onto an empty branch that rests on a DIFFERENT commit (found by +/// fuzzing the empties-between-owners fixture): the metadata-only arm treated every +/// empty-onto-empty move as a pure display reorder, so the subject's ref never re-pointed +/// and the projection reconciled by dropping it. Different-commit empties now take the +/// graph path and physically re-point. +#[test] +fn repro_managed_empty_move_across_commits() -> anyhow::Result<()> { + use but_meta::VirtualBranchesTomlMetadata; + + fn setup(meta: &mut VirtualBranchesTomlMetadata) { + add_stack_with_segments( + meta, + 1, + "e3", + StackState::InWorkspace, + &["e2", "B", "e1", "A"], + ); + add_stack_with_segments(meta, 2, "F", StackState::InWorkspace, &[]); + } + let (_tmp, mut ws, repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph( + "ws-ref-ws-commit-empties-between-owners", + setup, + )?; + + // e1 rests on A's tip; e3 rests on B's tip โ€” same stack, different commits. + managed_move_and_apply( + &repo, + &mut meta, + &mut ws, + "refs/heads/e1".try_into()?, + "refs/heads/e3".try_into()?, + )?; + assert_eq!( + repo.rev_parse_single("e1")?.detach(), + repo.rev_parse_single("e3")?.detach(), + "e1 must physically re-point onto e3's commit" + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 893d602 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:e1 on 893d602 {1} +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:e1 +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:e3 +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:e2 +โ”‚ โ”œโ”€โ”€ ๐Ÿ“™:B +โ”‚ โ”‚ โ””โ”€โ”€ ยท406dba0 (๐Ÿ˜๏ธ) +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ”‚ โ””โ”€โ”€ ยท26e45af (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:F on 893d602 {2} + โ””โ”€โ”€ ๐Ÿ“™:F + โ””โ”€โ”€ ยท9920891 (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +/// Deleting a branch label whose commits stay as an anonymous segment (found by fuzzing +/// mixed ops): the stack's remaining chain no longer had a positioned branch on the run's +/// START, so no claim pass bound it and its same-commit empty members fell out of the +/// projection (refs intact on disk). Anonymous runs now get a late claim pass that walks +/// the leg below the start. +#[test] +fn repro_managed_remove_owner_keeps_chain_empties() -> anyhow::Result<()> { + use but_core::ref_metadata::StackId; + use but_meta::VirtualBranchesTomlMetadata; + use but_workspace::branch::create_reference::{Anchor, Position}; + + fn setup(meta: &mut VirtualBranchesTomlMetadata) { + add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); + add_stack_with_segments(meta, 2, "C", StackState::InWorkspace, &["B"]); + } + fn test_stack_id(rn: &gix::refs::FullNameRef) -> StackId { + StackId::from_number_for_testing(rn.as_bstr().iter().map(|&b| b as u128).sum()) + } + let (_tmp, mut ws, repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph( + "ws-ref-ws-commit-single-stack-double-stack", + setup, + )?; + + // Empty B by squashing its commit away, fold A into the stack, then hang two fresh + // empties off it โ€” and delete C, the only ref left on the stack's tip commit. + let b_commit = repo.rev_parse_single("B")?.detach(); + let a_commit = repo.rev_parse_single("A")?.detach(); + let outcome = but_workspace::commit::squash_commits( + Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?, + vec![b_commit], + a_commit, + but_workspace::commit::squash_commits::MessageCombinationStrategy::KeepBoth, + )?; + let materialized = outcome.rebase.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; + let pm = ws.project_meta().clone(); + ws.refresh_from_head(&repo, &meta, pm)?; + managed_move_and_apply( + &repo, + &mut meta, + &mut ws, + r_ref("refs/heads/A"), + r_ref("refs/heads/B"), + )?; + for (name, anchor) in [ + ("refs/heads/fz-2", None), + ( + "refs/heads/fz-3", + Some(Anchor::AtSegment { + ref_name: std::borrow::Cow::Owned("refs/heads/B".try_into()?), + position: Position::Below, + }), + ), + ] { + ws = but_workspace::branch::create_reference( + gix::refs::FullName::try_from(name)?.as_ref(), + anchor, + &repo, + &ws, + &mut meta, + test_stack_id, + None, + )? + .expect("the workspace changed"); + } + but_workspace::branch::remove_reference( + r_ref("refs/heads/C"), + &repo, + &ws, + &mut meta, + Default::default(), + )? + .expect("C is deleted"); + let pm = ws.project_meta().clone(); + ws.refresh_from_head(&repo, &meta, pm)?; + + let projected: Vec<_> = ws + .display_stacks() + .expect("displayable") + .iter() + .flat_map(|stack| &stack.segments) + .filter_map(|seg| seg.ref_name().map(|n| n.shorten().to_string())) + .collect(); + for name in ["A", "B", "fz-3", "fz-2"] { + assert!( + projected.contains(&name.to_string()), + "{name} dropped from the projection: {projected:?}" + ); + } + Ok(()) +} + +/// Moving a branch out of a chain listed in not-yet-persisted metadata (found by fuzzing +/// mixed ops): the mid-flight projection saw the moved branch as another claimed chain's +/// territory MID-LEG and ended the walk there, vanishing the segments below โ€” which trips +/// the applied-stacks-projected sanity check. Foreign territory now only ends a walk at +/// another run's own start. +#[test] +fn repro_managed_move_into_anonymous_tip_stack() -> anyhow::Result<()> { + use but_meta::VirtualBranchesTomlMetadata; + + fn setup(meta: &mut VirtualBranchesTomlMetadata) { + add_stack_with_segments(meta, 1, "D", StackState::InWorkspace, &["A"]); + add_stack_with_segments(meta, 2, "E", StackState::InWorkspace, &["C", "B"]); + } + let (_tmp, mut ws, repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph( + "ws-ref-ws-commit-double-stack-triple-stack-files", + setup, + )?; + + // Anonymize stack 1's tip by deleting D, then move B (mid-stack owner from stack 2) + // onto A. The move's intermediate projection runs against metadata that still lists + // B in stack 2. + but_workspace::branch::remove_reference( + r_ref("refs/heads/D"), + &repo, + &ws, + &mut meta, + Default::default(), + )? + .expect("D is deleted"); + let pm = ws.project_meta().clone(); + ws.refresh_from_head(&repo, &meta, pm)?; + managed_move_and_apply( + &repo, + &mut meta, + &mut ws, + r_ref("refs/heads/B"), + r_ref("refs/heads/A"), + )?; + + let projected: Vec<_> = ws + .display_stacks() + .expect("displayable") + .iter() + .flat_map(|stack| &stack.segments) + .filter_map(|seg| seg.ref_name().map(|n| n.shorten().to_string())) + .collect(); + for name in ["A", "B", "C", "E"] { + assert!( + projected.contains(&name.to_string()), + "{name} dropped from the projection: {projected:?}" + ); + } + Ok(()) +} + +/// An all-empty stack must keep the commit it rests on as its base (found by fuzzing the +/// advanced-remote scenario): pruning the bound's integrated `main` segment from a stack +/// of only empties discarded the base stored on it, and the next anchored +/// `create_reference` hit `try_branch_resting_commit_id`'s "impossible" no-base case. +/// The old bottom's base now carries over when empty bottoms are removed. +#[test] +fn repro_managed_all_empty_stack_keeps_base() -> anyhow::Result<()> { + use but_core::ref_metadata::StackId; + use but_meta::VirtualBranchesTomlMetadata; + use but_workspace::branch::create_reference::{Anchor, Position}; + + fn setup(meta: &mut VirtualBranchesTomlMetadata) { + add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); + add_stack_with_segments(meta, 2, "B", StackState::InWorkspace, &[]); + } + fn test_stack_id(rn: &gix::refs::FullNameRef) -> StackId { + StackId::from_number_for_testing(rn.as_bstr().iter().map(|&b| b as u128).sum()) + } + let (_tmp, mut ws, repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph( + "ws-ref-ws-commit-two-stacks-advanced-remote", + setup, + )?; + + // Empty B onto the anonymous fork commit, then grow its all-empty stack around it. + let b_commit = repo.rev_parse_single("B")?.detach(); + let a_commit = repo.rev_parse_single("A")?.detach(); + let outcome = but_workspace::commit::squash_commits( + Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?, + vec![b_commit], + a_commit, + but_workspace::commit::squash_commits::MessageCombinationStrategy::KeepBoth, + )?; + let materialized = outcome.rebase.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; + let pm = ws.project_meta().clone(); + ws.refresh_from_head(&repo, &meta, pm)?; + + for (name, anchor_name, position) in [ + ("refs/heads/fz-1", "refs/heads/B", Position::Below), + ("refs/heads/fz-3", "refs/heads/fz-1", Position::Above), + ] { + ws = but_workspace::branch::create_reference( + gix::refs::FullName::try_from(name)?.as_ref(), + Some(Anchor::AtSegment { + ref_name: std::borrow::Cow::Owned(anchor_name.try_into()?), + position, + }), + &repo, + &ws, + &mut meta, + test_stack_id, + None, + )? + .expect("the workspace changed"); + let pm = ws.project_meta().clone(); + ws.refresh_from_head(&repo, &meta, pm)?; + } + let display_stacks = ws.display_stacks()?; + let all_empty_stack = display_stacks + .iter() + .find(|stack| { + stack + .segments + .iter() + .any(|seg| seg.ref_name().is_some_and(|n| n.shorten() == "B")) + }) + .expect("B's stack is projected"); + assert!( + all_empty_stack + .segments + .iter() + .all(|seg| seg.commits.is_empty()), + "the stack holds only empty branches" + ); + assert!( + all_empty_stack.base().is_some(), + "an all-empty stack keeps the commit it rests on as its base" + ); + Ok(()) +} + +/// Creating a reference BELOW an empty anchor placed it at the anchor's resting commit's +/// PARENT (found by fuzzing): the resting commit of an empty belongs to the segment below +/// it, so stepping to the parent skipped that segment's whole territory and scrambled the +/// stack. Below an empty now shares its resting commit; only the order differs. +#[test] +fn repro_managed_create_below_empty_anchor() -> anyhow::Result<()> { + use but_core::ref_metadata::StackId; + use but_meta::VirtualBranchesTomlMetadata; + use but_workspace::branch::create_reference::{Anchor, Position}; + + fn setup(meta: &mut VirtualBranchesTomlMetadata) { + add_stack_with_segments(meta, 1, "B", StackState::InWorkspace, &["A"]); + } + fn test_stack_id(rn: &gix::refs::FullNameRef) -> StackId { + StackId::from_number_for_testing(rn.as_bstr().iter().map(|&b| b as u128).sum()) + } + // One stack: B (empty) on top of A (owns one commit). + let (_tmp, ws, repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph( + "ws-ref-ws-commit-one-stack-with-empty-top-branch", + setup, + )?; + + let mut ws = but_workspace::branch::create_reference( + r_ref("refs/heads/below-B"), + Some(Anchor::AtSegment { + ref_name: std::borrow::Cow::Owned("refs/heads/B".try_into()?), + position: Position::Below, + }), + &repo, + &ws, + &mut meta, + test_stack_id, + None, + )? + .expect("the workspace changed"); + let pm = ws.project_meta().clone(); + ws.refresh_from_head(&repo, &meta, pm)?; + + assert_eq!( + repo.rev_parse_single("below-B")?.detach(), + repo.rev_parse_single("A")?.detach(), + "below an empty anchor shares its resting commit (A's tip), not A's parent" + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {1} + โ”œโ”€โ”€ ๐Ÿ“™:B + โ”œโ”€โ”€ ๐Ÿ“™:below-B + โ””โ”€โ”€ ๐Ÿ“™:A + โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) + +"#]] + ); + Ok(()) +} + +/// Moving a same-commit empty branch ACROSS stacks took the metadata-only arm (found by +/// fuzzing): the subject's old chain stayed in the layout, so the next materialize built a +/// stale workspace leg from it and the projection dropped the target branch. Cross-stack +/// empty moves now go through the editor, which merges the chains before materializing. +#[test] +fn repro_managed_empty_move_across_stacks_same_commit() -> anyhow::Result<()> { + use but_core::ref_metadata::StackId; + use but_meta::VirtualBranchesTomlMetadata; + + fn setup(meta: &mut VirtualBranchesTomlMetadata) { + add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); + add_stack_with_segments(meta, 2, "B", StackState::InWorkspace, &[]); + } + fn test_stack_id(rn: &gix::refs::FullNameRef) -> StackId { + StackId::from_number_for_testing(rn.as_bstr().iter().map(|&b| b as u128).sum()) + } + let (_tmp, mut ws, repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph( + "ws-ref-ws-commit-two-stacks-advanced-remote", + setup, + )?; + + // Stack A's commit relocates under B's, emptying A onto the fork commit. + managed_move_and_apply( + &repo, + &mut meta, + &mut ws, + r_ref("refs/heads/A"), + r_ref("refs/heads/B"), + )?; + let commits: Vec<_> = ws + .display_stacks() + .expect("displayable") + .iter() + .flat_map(|stack| &stack.segments) + .flat_map(|seg| { + let name = seg.ref_name().map(|n| n.shorten().to_string()); + seg.commits.iter().map(move |c| (name.clone(), c.id)) + }) + .collect(); + let a_commit = commits + .iter() + .find_map(|(n, id)| (n.as_deref() == Some("A")).then_some(*id)) + .expect("A owns a commit"); + let b_commit = commits + .iter() + .find_map(|(n, id)| (n.as_deref() == Some("B")).then_some(*id)) + .expect("B owns a commit"); + let outcome = but_workspace::commit::move_commit( + Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?, + b_commit, + a_commit, + but_rebase::graph_rebase::mutate::InsertSide::Below, + )?; + let materialized = outcome.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; + let pm = ws.project_meta().clone(); + ws.refresh_from_head(&repo, &meta, pm)?; + + // A fresh never-materialized chain moves onto B (empty, same commit, another stack). + ws = but_workspace::branch::create_reference( + r_ref("refs/heads/fz-4"), + None, + &repo, + &ws, + &mut meta, + test_stack_id, + None, + )? + .expect("the workspace changed"); + let pm = ws.project_meta().clone(); + ws.refresh_from_head(&repo, &meta, pm)?; + managed_move_and_apply( + &repo, + &mut meta, + &mut ws, + r_ref("refs/heads/fz-4"), + r_ref("refs/heads/B"), + )?; + + let projected: Vec<_> = ws + .display_stacks() + .expect("displayable") + .iter() + .flat_map(|stack| &stack.segments) + .filter_map(|seg| seg.ref_name().map(|n| n.shorten().to_string())) + .collect(); + for name in ["A", "fz-4", "B"] { + assert!( + projected.contains(&name.to_string()), + "{name} dropped from the projection: {projected:?}" + ); + } + Ok(()) +} + +/// A chain member naming the workspace lower bound vanished from the stacks (found by +/// fuzzing): after A's commit re-homed under B and A emptied onto the bound, the builder +/// let the chain's bottom empty name the bound commit โ€” a segment below the workspace that +/// the stacks never display. Chain members now always float above the bound as empties. +#[test] +fn repro_managed_chain_empty_never_names_the_bound() -> anyhow::Result<()> { + use but_core::ref_metadata::StackId; + use but_meta::VirtualBranchesTomlMetadata; + use but_workspace::branch::create_reference::{Anchor, Position}; + + fn setup(meta: &mut VirtualBranchesTomlMetadata) { + add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); + add_stack_with_segments(meta, 2, "B", StackState::InWorkspace, &[]); + } + fn test_stack_id(rn: &gix::refs::FullNameRef) -> StackId { + StackId::from_number_for_testing(rn.as_bstr().iter().map(|&b| b as u128).sum()) + } + let (_tmp, mut ws, repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph( + "ws-ref-ws-commit-two-stacks-advanced-remote", + setup, + )?; + + // fz-0 below owner A lands at the bound; A's commit then re-homes under B, + // emptying A onto the bound as well; finally B moves above A. + ws = but_workspace::branch::create_reference( + r_ref("refs/heads/fz-0"), + Some(Anchor::AtSegment { + ref_name: std::borrow::Cow::Owned("refs/heads/A".try_into()?), + position: Position::Below, + }), + &repo, + &ws, + &mut meta, + test_stack_id, + None, + )? + .expect("the workspace changed"); + let pm = ws.project_meta().clone(); + ws.refresh_from_head(&repo, &meta, pm)?; + + let a_commit = repo.rev_parse_single("A")?.detach(); + let b_commit = repo.rev_parse_single("B")?.detach(); + let outcome = but_workspace::commit::move_commit( + Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?, + a_commit, + b_commit, + but_rebase::graph_rebase::mutate::InsertSide::Below, + )?; + let materialized = outcome.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; + let pm = ws.project_meta().clone(); + ws.refresh_from_head(&repo, &meta, pm)?; + managed_move_and_apply( + &repo, + &mut meta, + &mut ws, + r_ref("refs/heads/B"), + r_ref("refs/heads/A"), + )?; + + let projected: Vec<_> = ws + .display_stacks() + .expect("displayable") + .iter() + .flat_map(|stack| &stack.segments) + .filter_map(|seg| seg.ref_name().map(|n| n.shorten().to_string())) + .collect(); + for name in ["B", "A", "fz-0"] { + assert!( + projected.contains(&name.to_string()), + "{name} dropped from the projection: {projected:?}" + ); + } + Ok(()) +} + +/// An applied chain with nothing above the workspace lower bound โ€” one branch on an +/// unreachable side leg, the other resting below the bound on integrated territory โ€” +/// was claimed by no run and vanished from the projection entirely (found by fuzzing +/// builder configurations): the next metadata write would have silently unapplied it. +/// Such chains now surface post-collect, once branch absorption by other stacks is known. +#[test] +fn repro_managed_chain_below_bound_surfaces() -> anyhow::Result<()> { + use but_meta::VirtualBranchesTomlMetadata; + + fn setup(meta: &mut VirtualBranchesTomlMetadata) { + add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); + add_stack_with_segments(meta, 2, "B", StackState::InWorkspace, &["D"]); + } + let (_tmp, ws, _repo, _meta, _description) = + named_writable_scenario_with_description_and_graph( + "ws-ref-ws-commit-chain-below-bound", + setup, + )?; + + let projected: Vec<_> = ws + .display_stacks() + .expect("displayable") + .iter() + .flat_map(|stack| &stack.segments) + .filter_map(|seg| seg.ref_name().map(|n| n.shorten().to_string())) + .collect(); + assert!( + projected.contains(&"D".to_string()), + "the chain's below-bound branch surfaces: {projected:?}" + ); + assert!( + ws.display_stacks() + .expect("displayable") + .iter() + .any(|stack| stack.id + == Some(but_core::ref_metadata::StackId::from_number_for_testing(2))), + "the chain keeps its stack identity" + ); + Ok(()) +} + +/// An applied EMPTY branch resting exactly at the target's unpulled tip (fuzz seed 145): +/// its chain must not leg the materialized workspace merge into target territory โ€” +/// re-merging there silently adopts the unpulled commits and projects the target's +/// local (`main`) as an applied stack. +#[test] +fn repro_managed_empty_at_unpulled_target_keeps_ws_parents() -> anyhow::Result<()> { + let (_tmp, mut ws, repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph( + "ws-ref-ws-commit-empty-at-unpulled-target", + |meta| { + add_stack_with_segments(meta, 1, "E", StackState::InWorkspace, &["B"]); + }, + )?; + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 7edd22c (origin/main, main, E) advanced +| * 04cdd5e (HEAD -> gitbutler/workspace) GitButler Workspace Commit +| * 7d7d38f (B) B1 +|/ +* 85efbe4 M + +"#]] + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 85efbe4 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {1} + โ”œโ”€โ”€ ๐Ÿ“™:B + โ”‚ โ””โ”€โ”€ ยท7d7d38f (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ๐Ÿ“™:E + +"#]] + ); + + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; + let materialized = editor.rebase()?.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; + let project_meta = ws.project_meta().clone(); + ws.refresh_from_head(&repo, &meta, project_meta)?; + + // The round trip re-merges the workspace commit over B alone; E stays put at the + // unpulled tip and settles as the chain's empty โ€” no stack tipped by `main`. + snapbox::assert_data_eq!( + visualize_commit_graph_all(&repo)?, + snapbox::str![[r#" +* 7edd22c (origin/main, main, E) advanced +| * 04cdd5e (HEAD -> gitbutler/workspace) GitButler Workspace Commit +| * 7d7d38f (B) B1 +|/ +* 85efbe4 M + +"#]] + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on 85efbe4 +โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {1} + โ”œโ”€โ”€ ๐Ÿ“™:B + โ”‚ โ””โ”€โ”€ ยท7d7d38f (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ๐Ÿ“™:E + +"#]] + ); + Ok(()) +} + +/// The target region's shape boundary lands MID-RUN of an advanced-outside branch's +/// segment (fuzz seed 378): re-minting the run's tail materialized those commits twice, +/// and the refs riding them tripped the one-name-one-reference layout assert. +#[test] +fn repro_target_region_boundary_mid_outside_run() -> anyhow::Result<()> { + let (_tmp, ws, _repo, _meta, _description) = + named_writable_scenario_with_description_and_graph( + "ws-ref-ws-commit-region-boundary-mid-outside-run", + |meta| { + add_stack_with_segments(meta, 1, "W", StackState::InWorkspace, &[]); + add_stack_with_segments(meta, 2, "A", StackState::InWorkspace, &[]); + add_stack_with_segments(meta, 3, "D", StackState::InWorkspace, &["C"]); + }, + )?; + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ4 on 85efbe4 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:W on 85efbe4 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:W +โ””โ”€โ”€ โ‰ก:D {3} + โ””โ”€โ”€ :D + +"#]] + ); + Ok(()) +} + +/// A chain with content only OUTSIDE the workspace (A advanced-outside) and an empty +/// member (D) resting INSIDE the anonymous lane the chain claims (fuzz seed 523): the +/// splice must interpose on the lane's own edge, not leg the workspace merge onto D's +/// anchor โ€” that grew the merge a redundant parent and reshuffled stack identities on +/// the next materialize. Pure-empty chains keep their fresh workspace leg. +#[test] +fn repro_managed_empty_inside_anonymous_lane_keeps_ws_parents() -> anyhow::Result<()> { + let (_tmp, mut ws, repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph( + "ws-ref-ws-commit-empty-inside-anonymous-lane", + |meta| { + add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &["D"]); + }, + )?; + let ws_commit_before = repo.rev_parse_single("gitbutler/workspace")?.detach(); + let projected_before = graph_workspace(&ws).to_string(); + snapbox::assert_data_eq!( + &*projected_before, + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/mainโ‡ฃ1 on f090f6e +โ””โ”€โ”€ โ‰ก:anon: on f090f6e {1} + โ”œโ”€โ”€ :anon: + โ”‚ โ””โ”€โ”€ ยท9f9f57e (๐Ÿ˜๏ธ) + โ””โ”€โ”€ ๐Ÿ“™:D + +"#]] + ); + + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; + let materialized = editor.rebase()?.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; + let project_meta = ws.project_meta().clone(); + ws.refresh_from_head(&repo, &meta, project_meta)?; + + assert_eq!( + repo.rev_parse_single("gitbutler/workspace")?.detach(), + ws_commit_before, + "a no-op round trip keeps the workspace merge bit for bit" + ); + assert_eq!( + graph_workspace(&ws).to_string(), + projected_before, + "the projection survives the round trip unchanged" + ); + Ok(()) +} + +/// The member-carrier twin (fuzz seed 668): the chain's empty rests on its OWN +/// content's leg base (A empty at E's leg base = the target tip). The splice +/// interposes on the chain member's edge โ€” a fresh workspace leg would re-merge the +/// target tip in as a redundant parent. +#[test] +fn repro_managed_empty_at_own_leg_base_keeps_ws_parents() -> anyhow::Result<()> { + let (_tmp, mut ws, repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph( + "ws-ref-ws-commit-empty-at-own-leg-base", + |meta| { + add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &["E"]); + }, + )?; + let ws_commit_before = repo.rev_parse_single("gitbutler/workspace")?.detach(); + let projected_before = graph_workspace(&ws).to_string(); + snapbox::assert_data_eq!( + &*projected_before, + snapbox::str![[r#" +๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 29ab206 +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 29ab206 {1} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A +โ””โ”€โ”€ โ‰ก๐Ÿ“™:E on 29ab206 + โ””โ”€โ”€ ๐Ÿ“™:E + โ””โ”€โ”€ ยทef6688b (๐Ÿ˜๏ธ) + +"#]] + ); + + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; + let materialized = editor.rebase()?.materialize()?; + ws.refresh_from_commit_graph(materialized.arena().clone(), &repo, materialized.meta)?; + let project_meta = ws.project_meta().clone(); + ws.refresh_from_head(&repo, &meta, project_meta)?; + + assert_eq!( + repo.rev_parse_single("gitbutler/workspace")?.detach(), + ws_commit_before, + "a no-op round trip keeps the workspace merge bit for bit" + ); + assert_eq!( + graph_workspace(&ws).to_string(), + projected_before, + "the projection survives the round trip unchanged" + ); + Ok(()) +} + +fn r_ref(name: &str) -> &gix::refs::FullNameRef { + name.try_into().expect("statically valid ref name") +} + +fn managed_move_and_apply( + repo: &gix::Repository, + meta: &mut but_meta::VirtualBranchesTomlMetadata, + ws: &mut but_graph::Workspace, + subject: &gix::refs::FullNameRef, + target: &gix::refs::FullNameRef, +) -> anyhow::Result<()> { + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), meta, repo)?; + but_workspace::branch::move_branch(editor, ws, subject, target)?.apply(ws, repo)?; + let project_meta = ws.project_meta().clone(); + ws.refresh_from_head(repo, meta, project_meta)?; + Ok(()) +} + /// Tests for `move_branch` in single-branch (ad-hoc) mode, where `HEAD` is on a plain local branch /// (no `gitbutler/workspace` commit) and the tip-to-base order of same-commit empty branches lives /// in the `branch_order` metadata table rather than in `Workspace` metadata. @@ -1498,7 +2397,7 @@ mod single_branch_mode { stack_id_for_name, None, )? - .into_owned(); + .expect("the workspace changed"); } Ok((tmp, repo, meta, project_meta)) @@ -2098,4 +2997,91 @@ mod single_branch_mode { ); Ok(()) } + + /// Replay a fuzzer-found move sequence over the commit-with-empties fixture, printing the + /// workspace order before each move; fails on any `BUG:`-classed error (an editor + /// well-formedness violation). + fn replay_move_sequence(attempts: &[(&str, &str)]) -> anyhow::Result<()> { + let (_tmp, repo, legacy_meta) = + named_writable_scenario("single-branch-commit-with-empties")?; + let project_meta = project_meta(&legacy_meta); + let mut meta = branch_order_meta(&repo)?; + meta.set_branch_stack_order(&[ + r("refs/heads/empty-top").to_owned(), + r("refs/heads/empty-low").to_owned(), + r("refs/heads/commit-branch").to_owned(), + r("refs/heads/single-branch-fixture").to_owned(), + ])?; + + for (i, (subject, target)) in attempts.iter().enumerate() { + let ws = but_graph::Workspace::from_head( + &repo, + &meta, + project_meta.clone(), + Options::limited(), + )?; + let order: Vec<_> = ws + .display_stacks() + .expect("displayable") + .iter() + .flat_map(|stack| &stack.segments) + .filter_map(|seg| seg.ref_name().map(|n| n.shorten().to_string())) + .collect(); + eprintln!("step {i}: order before = {order:?}, move {subject} -> {target}"); + match move_branch_and_apply( + &repo, + &mut meta, + project_meta.clone(), + r(subject), + r(target), + ) { + Ok(order) => eprintln!(" ok, new order = {order:?}"), + Err(e) => { + let msg = format!("{e:?}"); + eprintln!(" err: {msg}"); + assert!(!msg.contains("BUG:"), "collision at step {i}: {msg}"); + } + } + } + Ok(()) + } + + /// The fuzzer's seed-3 finding: a commit-owning branch moving down past an empty left the + /// crossed empty carrying the re-added parent edge in a parallel group โ€” a rank-0 collision. + #[test] + fn repro_seed3_ref_position_collision() -> anyhow::Result<()> { + replay_move_sequence(&[ + ("refs/heads/commit-branch", "refs/heads/empty-top"), + ("refs/heads/single-branch-fixture", "refs/heads/empty-top"), + ("refs/heads/commit-branch", "refs/heads/empty-low"), + ("refs/heads/commit-branch", "refs/heads/empty-low"), + ("refs/heads/empty-top", "refs/heads/commit-branch"), + ( + "refs/heads/commit-branch", + "refs/heads/single-branch-fixture", + ), + ])?; + // The last move ([empty-top, commit-branch, empty-low, base] with commit-branch moved + // above the base) lifts both empties above commit-branch; they must share its tip. + Ok(()) + } + + /// The fuzzer's seed-120 finding: moving the base branch above everything let the empties + /// ride its relocated commit while the persisted order said otherwise; the diverged state + /// then collided on a later move. The unrepresentable step now refuses cleanly (an empty + /// may not sit below the bottom branch) and the rest reconcile. + #[test] + fn repro_seed120_ref_position_collision() -> anyhow::Result<()> { + replay_move_sequence(&[ + ("refs/heads/empty-low", "refs/heads/empty-top"), + ("refs/heads/commit-branch", "refs/heads/empty-low"), + ("refs/heads/empty-low", "refs/heads/single-branch-fixture"), + ( + "refs/heads/single-branch-fixture", + "refs/heads/commit-branch", + ), + ("refs/heads/empty-top", "refs/heads/empty-low"), + ("refs/heads/empty-low", "refs/heads/commit-branch"), + ]) + } } diff --git a/crates/but-workspace/tests/workspace/commit/mod.rs b/crates/but-workspace/tests/workspace/commit/mod.rs index e0b6631cfed..a96614e113e 100644 --- a/crates/but-workspace/tests/workspace/commit/mod.rs +++ b/crates/but-workspace/tests/workspace/commit/mod.rs @@ -65,6 +65,7 @@ parent d3cce74a69ee3b0e1cbea65b53908d602d6bda26 author GitButler 946771200 +0000 committer GitButler 946771200 +0000 encoding UTF-8 +gitbutler-workspace-parents refs/heads/add-A GitButler Workspace Commit @@ -88,7 +89,7 @@ https://docs.gitbutler.com/features/branch-management/integration-branch out.to_debug(), snapbox::str![[r#" Outcome { - workspace_commit_id: Sha1(31f3d99d4b12a57c1c21053ab3ae5da160247044), + workspace_commit_id: Sha1(0e60227107f657eb6c5d1ce4a2521a1f1fb486a8), stacks: [ Stack { tip: d3cce74, name: "add-A" }, ], @@ -128,7 +129,7 @@ f53c910 out.to_debug(), snapbox::str![[r#" Outcome { - workspace_commit_id: Sha1(391a453230c141ac5f81d7203ac90c7e66ea9acf), + workspace_commit_id: Sha1(2b0f584a05fa78d69e953bc5a2f86fdcdeee256f), stacks: [ Stack { tip: 27ab782, name: "add-D" }, Stack { tip: d3cce74, name: "add-A" }, @@ -154,6 +155,7 @@ parent 115e41b0ffb7fcb56f91a9fb64cf4a7b786c1bea author GitButler 946771200 +0000 committer GitButler 946771200 +0000 encoding UTF-8 +gitbutler-workspace-parents refs/heads/add-D refs/heads/add-A refs/heads/add-C refs/heads/add-B GitButler Workspace Commit @@ -249,7 +251,7 @@ https://docs.gitbutler.com/features/branch-management/integration-branch out.to_debug(), snapbox::str![[r#" Outcome { - workspace_commit_id: Sha1(24731387ff4bfbd9c803b388d8f65ca64a9d87b3), + workspace_commit_id: Sha1(53efd3bcfec888d9a60faac814d00539e66b8049), stacks: [ Stack { tip: d3cce74, name: "add-A" }, Stack { tip: 34c4591, name: None }, @@ -316,7 +318,7 @@ Outcome { out.to_debug(), snapbox::str![[r#" Outcome { - workspace_commit_id: Sha1(b8ba7bd37ac1a1f9a0e7f29ddf83acc249d7b866), + workspace_commit_id: Sha1(042643faa832a77f8cc4aefa1191b4a1a1ccd641), stacks: [ Stack { tip: d3cce74, name: "clean-A" }, Stack { tip: 115e41b, name: "clean-B" }, @@ -370,7 +372,7 @@ Outcome { out.to_debug(), snapbox::str![[r#" Outcome { - workspace_commit_id: Sha1(e444bfa38570217271f5df56c3fe26ed57a7e023), + workspace_commit_id: Sha1(ed2ea047b2d1f065d7b4e75fb27422dacc5f6344), stacks: [ Stack { tip: d3cce74, name: "clean-A" }, Stack { tip: bf09eae, name: "conflict-F1" }, @@ -456,7 +458,7 @@ Outcome { out.to_debug(), snapbox::str![[r#" Outcome { - workspace_commit_id: Sha1(e25b36b3a4192701e5e91a00d1c2fe07b9888338), + workspace_commit_id: Sha1(aada2325e41f418e201e129522c51877715d5b7a), stacks: [ Stack { tip: 8450331, name: "tip-conflicted" }, Stack { tip: 8ab1c4d, name: "unrelated" }, @@ -529,7 +531,7 @@ Outcome { out.to_debug(), snapbox::str![[r#" Outcome { - workspace_commit_id: Sha1(4aede0de89327f3afde3db1ed9f83f368f67d501), + workspace_commit_id: Sha1(4eb9a5558eda35ef136d77c31d970a044a585260), stacks: [ Stack { tip: d3cce74, name: "clean-A" }, Stack { tip: 6777bd8, name: "conflict-C1" }, @@ -560,6 +562,7 @@ parent 115e41b0ffb7fcb56f91a9fb64cf4a7b786c1bea author GitButler 946771200 +0000 committer GitButler 946771200 +0000 encoding UTF-8 +gitbutler-workspace-parents refs/heads/clean-A refs/heads/conflict-C1 refs/heads/clean-B GitButler Workspace Commit @@ -607,7 +610,7 @@ fc2bf71 out.to_debug(), snapbox::str![[r#" Outcome { - workspace_commit_id: Sha1(fd9723d257cd09656a2f8bb24d3dd2138f88b343), + workspace_commit_id: Sha1(a9a3d2edc3fc0e9eaabaef87eed061e49cbbe685), stacks: [ Stack { tip: d3cce74, name: "clean-A" }, Stack { tip: 115e41b, name: "clean-B" }, @@ -637,6 +640,7 @@ parent f8392d239500de94b23f42c8ab5508dae1b3b657 author GitButler 946771200 +0000 committer GitButler 946771200 +0000 encoding UTF-8 +gitbutler-workspace-parents refs/heads/clean-A refs/heads/clean-B refs/heads/conflict-C2 GitButler Workspace Commit @@ -683,7 +687,7 @@ https://docs.gitbutler.com/features/branch-management/integration-branch out.to_debug(), snapbox::str![[r#" Outcome { - workspace_commit_id: Sha1(e297aee321879eb991bb5360859cbddb5891a316), + workspace_commit_id: Sha1(72d4000eeef2f10ade236b7df9663b227ce7314d), stacks: [ Stack { tip: 6777bd8, name: "conflict-C1" }, Stack { tip: d3cce74, name: "clean-A" }, @@ -760,6 +764,7 @@ Outcome { .to_full_name(short_name) .expect("known good short ref name"), archived: false, + parents: None, }], }) .collect() diff --git a/crates/but-workspace/tests/workspace/commit/move_commit.rs b/crates/but-workspace/tests/workspace/commit/move_commit.rs index d56637a9b34..5b9c591a6b9 100644 --- a/crates/but-workspace/tests/workspace/commit/move_commit.rs +++ b/crates/but-workspace/tests/workspace/commit/move_commit.rs @@ -745,9 +745,9 @@ fn move_commit_to_empty_branch() -> anyhow::Result<()> { snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" -* 6d5c23e (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* e16ce30 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ -| * 09d8e52 (B) A +* | 09d8e52 (B) A |/ * 85efbe4 (origin/main, main, A) M @@ -759,11 +759,11 @@ fn move_commit_to_empty_branch() -> anyhow::Result<()> { graph_workspace(&ws).to_string(), snapbox::str![[r#" ๐Ÿ“•๐Ÿ˜๏ธ:gitbutler/workspace[๐ŸŒณ] <> โœ“refs/remotes/origin/main on 85efbe4 -โ”œโ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} -โ”‚ โ””โ”€โ”€ ๐Ÿ“™:A -โ””โ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} - โ””โ”€โ”€ ๐Ÿ“™:B - โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) +โ”œโ”€โ”€ โ‰ก๐Ÿ“™:B on 85efbe4 {2} +โ”‚ โ””โ”€โ”€ ๐Ÿ“™:B +โ”‚ โ””โ”€โ”€ ยท09d8e52 (๐Ÿ˜๏ธ) +โ””โ”€โ”€ โ‰ก๐Ÿ“™:A on 85efbe4 {1} + โ””โ”€โ”€ ๐Ÿ“™:A "#]] ); diff --git a/crates/but-workspace/tests/workspace/commit/uncommit_changes.rs b/crates/but-workspace/tests/workspace/commit/uncommit_changes.rs index 9b9de55ae68..ccf7de61651 100644 --- a/crates/but-workspace/tests/workspace/commit/uncommit_changes.rs +++ b/crates/but-workspace/tests/workspace/commit/uncommit_changes.rs @@ -894,9 +894,10 @@ fn uncommit_changes_from_commits_handles_parallel_stacks() -> Result<()> { +++ after +*-. [C5] (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|\ \ -+| * | [C6] (stack-b) stack B adds file ++| | * [C6] (stack-b) stack B adds file +| |/ -+* / [C7] (stack-a) stack A adds file ++|/| ++| * [C7] (stack-a) stack A adds file +|/ +* [C4] (main) base diff --git a/crates/but-workspace/tests/workspace/graph.rs b/crates/but-workspace/tests/workspace/graph.rs index 4c09455a40b..fc482c95bc7 100644 --- a/crates/but-workspace/tests/workspace/graph.rs +++ b/crates/but-workspace/tests/workspace/graph.rs @@ -32,6 +32,7 @@ use crate::ref_info::with_workspace_commit::utils::{ fn detailed( fixture: &str, target: Option<&str>, + stacks: &[&[&str]], ) -> Result<(gix::Repository, DetailedGraphWorkspace)> { let repo = crate::utils::read_only_in_memory_scenario(fixture)?; let mut meta = VirtualBranchesTomlMetadata::from_path( @@ -39,6 +40,29 @@ fn detailed( .join(".git") .join("should-never-be-written.toml"), )?; + // The partition comes from DECLARED stacks; a fixture without declarations + // exercises the metadata-less legacy shapes. + if !stacks.is_empty() { + let ws_ref: gix::refs::FullName = but_core::WORKSPACE_REF_NAME.try_into()?; + let mut ws_md = meta.workspace(ws_ref.as_ref())?; + for (idx, branches) in stacks.iter().enumerate() { + ws_md.stacks.push(but_core::ref_metadata::WorkspaceStack { + id: but_core::ref_metadata::StackId::from_number_for_testing(idx as u128 + 1), + branches: branches + .iter() + .map(|name| { + Ok(but_core::ref_metadata::WorkspaceStackBranch { + ref_name: format!("refs/heads/{name}").try_into()?, + archived: false, + parents: None, + }) + }) + .collect::>()?, + workspacecommit_relation: but_core::ref_metadata::WorkspaceCommitRelation::Merged, + }); + } + meta.set_workspace(&ws_md)?; + } let project_meta = ProjectMeta { target_ref: target.map(gix::refs::FullName::try_from).transpose()?, // Bound the graph at the target commit too, so the projection is @@ -356,7 +380,7 @@ fn row_glyph_label(data: &GraphRowData) -> (&'static str, String) { /// the single stack reaches all the way down to `base`. #[test] fn single_stack_no_target() -> Result<()> { - let (repo, detailed) = detailed("workspace-linear", None)?; + let (repo, detailed) = detailed("workspace-linear", None, &[&["c", "b", "a", "base"]])?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" @@ -399,7 +423,11 @@ fn single_stack_no_target() -> Result<()> { /// The same linear workspace bounded by a target at `base`. #[test] fn single_stack_with_target() -> Result<()> { - let (_repo, detailed) = detailed("workspace-linear", Some("refs/heads/base"))?; + let (_repo, detailed) = detailed( + "workspace-linear", + Some("refs/heads/base"), + &[&["c", "b", "a", "base"]], + )?; snapbox::assert_data_eq!( render(&detailed), snapbox::str![[r#" @@ -431,7 +459,11 @@ fn single_stack_with_target() -> Result<()> { /// they share ancestry, so de-duplication merges them into a single stack. #[test] fn overlapping_stacks_merge_into_one() -> Result<()> { - let (repo, detailed) = detailed("workspace-with-empty-stack", None)?; + let (repo, detailed) = detailed( + "workspace-with-empty-stack", + None, + &[&["stack-1"], &["stack-2"]], + )?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" @@ -456,14 +488,13 @@ fn overlapping_stacks_merge_into_one() -> Result<()> { โ—Ž 0 refs/heads/stack-1 โ— 1 2169646 Commit D โ— 2 46ef828 Commit C -โ—Ž 3 refs/heads/stack-2 -โ— 4 f555940 Commit A -โ— 5 d664be0 Commit B -โ— 6 fafd9d0 init linear ref=0 rows=[0, 1, 2] - linear ref=3 rows=[3, 4, 5, 6] reference ref=0 rows=[0, 1, 2] - reference ref=3 rows=[3, 4, 5, 6] + +# Stack 1 +โ—Ž 0 refs/heads/stack-2 + linear ref=0 rows=[0] + reference ref=0 rows=[0] "#]] ); Ok(()) @@ -472,7 +503,11 @@ fn overlapping_stacks_merge_into_one() -> Result<()> { /// Three stacks that all point at the same base commit collapse into one stack. #[test] fn three_stacks_same_base_collapse() -> Result<()> { - let (repo, detailed) = detailed("workspace-with-three-empty-stacks", None)?; + let (repo, detailed) = detailed( + "workspace-with-three-empty-stacks", + None, + &[&["stack-1"], &["stack-2"], &["stack-3"]], + )?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" @@ -488,15 +523,21 @@ fn three_stacks_same_base_collapse() -> Result<()> { snapbox::str![[r#" # Stack 0 โ—Ž 0 refs/heads/stack-1 -โ—Ž 1 refs/heads/stack-2 -โ—Ž 2 refs/heads/stack-3 -โ— 3 fafd9d0 init - linear ref=0 rows=[0] - linear ref=1 rows=[1] - linear ref=2 rows=[2, 3] - reference ref=0 rows=[0] - reference ref=1 rows=[1] - reference ref=2 rows=[2, 3] +โ— 1 fafd9d0 init + linear ref=0 rows=[0, 1] + reference ref=0 rows=[0, 1] + +# Stack 1 +โ—Ž 0 refs/heads/stack-2 +โ— 1 fafd9d0 init + linear ref=0 rows=[0, 1] + reference ref=0 rows=[0, 1] + +# Stack 2 +โ—Ž 0 refs/heads/stack-3 +โ— 1 fafd9d0 init + linear ref=0 rows=[0, 1] + reference ref=0 rows=[0, 1] "#]] ); Ok(()) @@ -507,7 +548,7 @@ fn three_stacks_same_base_collapse() -> Result<()> { /// computes exclusive reachability. #[test] fn divergent_stacks_sharing_base_merge() -> Result<()> { - let (repo, detailed) = detailed("workspace-two-stacks", None)?; + let (repo, detailed) = detailed("workspace-two-stacks", None, &[&["stack-a"], &["stack-b"]])?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" @@ -530,18 +571,21 @@ fn divergent_stacks_sharing_base_merge() -> Result<()> { โ—Ž 0 refs/heads/stack-a โ— 1 49c06ff A2 โ— 2 ff76d2f A1 -โ”‚ โ—Ž 3 refs/heads/stack-b -โ”‚ โ— 4 afc3f8f B2 -โ”‚ โ— 5 b3ee99c B1 -โ”œโ”€โ•ฏ -โ—Ž 6 refs/heads/main -โ— 7 965998b base +โ—Ž 3 refs/heads/main linear ref=0 rows=[0, 1, 2] - linear ref=3 rows=[3, 4, 5] - linear ref=6 rows=[6, 7] + linear ref=3 rows=[3] reference ref=0 rows=[0, 1, 2] - reference ref=3 rows=[3, 4, 5] - reference ref=6 rows=[6, 7] + reference ref=3 rows=[3] + +# Stack 1 +โ—Ž 0 refs/heads/stack-b +โ— 1 afc3f8f B2 +โ— 2 b3ee99c B1 +โ—Ž 3 refs/heads/main + linear ref=0 rows=[0, 1, 2] + linear ref=3 rows=[3] + reference ref=0 rows=[0, 1, 2] + reference ref=3 rows=[3] "#]] ); Ok(()) @@ -551,7 +595,11 @@ fn divergent_stacks_sharing_base_merge() -> Result<()> { /// `base`). #[test] fn divergent_stacks_sharing_excluded_base_stay_separate_with_target() -> Result<()> { - let (_repo, detailed) = detailed("workspace-two-stacks", Some("refs/heads/main"))?; + let (_repo, detailed) = detailed( + "workspace-two-stacks", + Some("refs/heads/main"), + &[&["stack-a"], &["stack-b"]], + )?; snapbox::assert_data_eq!( render(&detailed), snapbox::str![[r#" @@ -559,15 +607,21 @@ fn divergent_stacks_sharing_excluded_base_stay_separate_with_target() -> Result< โ—Ž 0 refs/heads/stack-a โ— 1 49c06ff A2 โ— 2 ff76d2f A1 +โ—Ž 3 refs/heads/main linear ref=0 rows=[0, 1, 2] + linear ref=3 rows=[3] reference ref=0 rows=[0, 1, 2] + reference ref=3 rows=[3] # Stack 1 โ—Ž 0 refs/heads/stack-b โ— 1 afc3f8f B2 โ— 2 b3ee99c B1 +โ—Ž 3 refs/heads/main linear ref=0 rows=[0, 1, 2] + linear ref=3 rows=[3] reference ref=0 rows=[0, 1, 2] + reference ref=3 rows=[3] "#]] ); Ok(()) @@ -577,7 +631,7 @@ fn divergent_stacks_sharing_excluded_base_stay_separate_with_target() -> Result< /// lands in one stack. #[test] fn pegged_no_target() -> Result<()> { - let (repo, detailed) = detailed("four-commits", None)?; + let (repo, detailed) = detailed("four-commits", None, &[])?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" @@ -607,7 +661,11 @@ fn pegged_no_target() -> Result<()> { /// Two stacks with no shared history stay genuinely separate. #[test] fn disjoint_stacks_stay_separate() -> Result<()> { - let (repo, detailed) = detailed("workspace-disjoint-stacks", None)?; + let (repo, detailed) = detailed( + "workspace-disjoint-stacks", + None, + &[&["stack-a"], &["stack-b"]], + )?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" @@ -626,22 +684,16 @@ fn disjoint_stacks_stay_separate() -> Result<()> { render(&detailed), snapbox::str![[r#" # Stack 0 -โ—Ž 0 refs/heads/stack-a -โ— 1 49c06ff A2 -โ— 2 ff76d2f A1 -โ—Ž 3 refs/heads/main -โ— 4 965998b base - linear ref=0 rows=[0, 1, 2] - linear ref=3 rows=[3, 4] - reference ref=0 rows=[0, 1, 2] - reference ref=3 rows=[3, 4] - -# Stack 1 โ—Ž 0 refs/heads/stack-b โ— 1 cb7021b B2 โ— 2 ce3278a B1 linear ref=0 rows=[0, 1, 2] reference ref=0 rows=[0, 1, 2] + +# Stack 1 +โ—Ž 0 refs/heads/stack-a + linear ref=0 rows=[0] + reference ref=0 rows=[0] "#]] ); Ok(()) @@ -650,7 +702,11 @@ fn disjoint_stacks_stay_separate() -> Result<()> { /// The same disjoint stacks, bounded by a target at `main`. #[test] fn disjoint_stacks_stay_separate_with_target() -> Result<()> { - let (_repo, detailed) = detailed("workspace-disjoint-stacks", Some("refs/heads/main"))?; + let (_repo, detailed) = detailed( + "workspace-disjoint-stacks", + Some("refs/heads/main"), + &[&["stack-a"], &["stack-b"]], + )?; snapbox::assert_data_eq!( render(&detailed), snapbox::str![[r#" @@ -658,8 +714,11 @@ fn disjoint_stacks_stay_separate_with_target() -> Result<()> { โ—Ž 0 refs/heads/stack-a โ— 1 49c06ff A2 โ— 2 ff76d2f A1 +โ—Ž 3 refs/heads/main linear ref=0 rows=[0, 1, 2] + linear ref=3 rows=[3] reference ref=0 rows=[0, 1, 2] + reference ref=3 rows=[3] # Stack 1 โ—Ž 0 refs/heads/stack-b @@ -678,7 +737,11 @@ fn disjoint_stacks_stay_separate_with_target() -> Result<()> { /// `reference_segment` spans multiple commits within one linear stack. #[test] fn stacked_dependent_branches_partition_per_reference() -> Result<()> { - let (repo, detailed) = detailed("workspace-stacked-branches", None)?; + let (repo, detailed) = detailed( + "workspace-stacked-branches", + None, + &[&["branch-top", "branch-bottom"]], + )?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" @@ -718,7 +781,11 @@ fn stacked_dependent_branches_partition_per_reference() -> Result<()> { /// reference segment becomes header-only while the branch segments are intact. #[test] fn stacked_dependent_branches_with_target() -> Result<()> { - let (_repo, detailed) = detailed("workspace-stacked-branches", Some("refs/heads/main"))?; + let (_repo, detailed) = detailed( + "workspace-stacked-branches", + Some("refs/heads/main"), + &[&["branch-top", "branch-bottom"]], + )?; snapbox::assert_data_eq!( render(&detailed), snapbox::str![[r#" @@ -729,10 +796,13 @@ fn stacked_dependent_branches_with_target() -> Result<()> { โ—Ž 3 refs/heads/branch-bottom โ— 4 d27b21f bottom 2 โ— 5 a3a5a44 bottom 1 +โ—Ž 6 refs/heads/main linear ref=0 rows=[0, 1, 2] linear ref=3 rows=[3, 4, 5] + linear ref=6 rows=[6] reference ref=0 rows=[0, 1, 2] reference ref=3 rows=[3, 4, 5] + reference ref=6 rows=[6] "#]] ); Ok(()) @@ -744,7 +814,7 @@ fn stacked_dependent_branches_with_target() -> Result<()> { /// `main` belongs to `main`. #[test] fn non_linear_reference_segment_with_internal_merge() -> Result<()> { - let (repo, detailed) = detailed("workspace-merge-in-stack", None)?; + let (repo, detailed) = detailed("workspace-merge-in-stack", None, &[&["feature"]])?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" @@ -764,19 +834,14 @@ fn non_linear_reference_segment_with_internal_merge() -> Result<()> { snapbox::str![[r#" # Stack 0 โ—Ž 0 refs/heads/feature -โ— 1 d585e46 M -โ”œโ”€โ•ฎ -โ— โ”‚ 2 6339d5b P -โ”‚ โ— 3 ed2a973 Q -โ”œโ”€โ•ฏ -โ—Ž 4 refs/heads/main -โ— 5 965998b base - linear ref=0 rows=[0] - linear ref=- rows=[1] - linear ref=- rows=[2, 3] - linear ref=4 rows=[4, 5] - reference ref=0 rows=[0, 1, 2, 3] - reference ref=4 rows=[4, 5] +โ— 1 d585e46 M +โ— 2 6339d5b P +โ—Ž 3 refs/heads/main +โ— 4 965998b base + linear ref=0 rows=[0, 1, 2] + linear ref=3 rows=[3, 4] + reference ref=0 rows=[0, 1, 2] + reference ref=3 rows=[3, 4] "#]] ); Ok(()) @@ -787,7 +852,11 @@ fn non_linear_reference_segment_with_internal_merge() -> Result<()> { /// `stack-y`, so it is included in *both* of their reference segments. #[test] fn shared_commit_belongs_to_both_reference_segments() -> Result<()> { - let (repo, detailed) = detailed("workspace-shared-commit", None)?; + let (repo, detailed) = detailed( + "workspace-shared-commit", + None, + &[&["stack-x"], &["stack-y"]], + )?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" @@ -808,19 +877,14 @@ fn shared_commit_belongs_to_both_reference_segments() -> Result<()> { # Stack 0 โ—Ž 0 refs/heads/stack-x โ— 1 9f0269c X1 -โ”‚ โ—Ž 2 refs/heads/stack-y -โ”‚ โ— 3 e6d5410 Y1 -โ”œโ”€โ•ฏ -โ— 4 d2bff94 S -โ—Ž 5 refs/heads/main -โ— 6 965998b base linear ref=0 rows=[0, 1] - linear ref=2 rows=[2, 3] - linear ref=- rows=[4] - linear ref=5 rows=[5, 6] - reference ref=0 rows=[0, 1, 4] - reference ref=2 rows=[2, 3, 4] - reference ref=5 rows=[5, 6] + reference ref=0 rows=[0, 1] + +# Stack 1 +โ—Ž 0 refs/heads/stack-y +โ— 1 e6d5410 Y1 + linear ref=0 rows=[0, 1] + reference ref=0 rows=[0, 1] "#]] ); Ok(()) @@ -837,14 +901,20 @@ fn shared_commit_belongs_to_both_reference_segments() -> Result<()> { /// this metadata-free projection harness intentionally does not set up. #[test] fn push_status_nothing_to_push_and_unpushed() -> Result<()> { - let (_repo, detailed) = detailed("workspace-two-stacks", Some("refs/heads/main"))?; + let (_repo, detailed) = detailed( + "workspace-two-stacks", + Some("refs/heads/main"), + &[&["stack-a"], &["stack-b"]], + )?; snapbox::assert_data_eq!( render_push_status(&detailed), snapbox::str![[r#" # Stack 0 refs/heads/stack-a push=CompletelyUnpushed combined=CompletelyUnpushed remote=- +refs/heads/main push=NothingToPush combined=NothingToPush remote=refs/remotes/origin/main # Stack 1 refs/heads/stack-b push=CompletelyUnpushed combined=CompletelyUnpushed remote=- +refs/heads/main push=NothingToPush combined=NothingToPush remote=refs/remotes/origin/main "#]] ); Ok(()) @@ -879,8 +949,10 @@ fn integration_status_marks_fully_integrated_branch() -> Result<()> { snapbox::str![[r#" # Stack 0 refs/heads/A push=Integrated combined=Integrated remote=- +refs/heads/main push=UnpushedCommitsRequiringForce combined=UnpushedCommitsRequiringForce remote=refs/remotes/origin/main # Stack 1 refs/heads/B push=CompletelyUnpushed combined=UnpushedCommitsRequiringForce remote=- +refs/heads/main push=UnpushedCommitsRequiringForce combined=UnpushedCommitsRequiringForce remote=refs/remotes/origin/main "#]] ); // `add A1` (== origin/main) is integrated; `add B1` is local-only. @@ -920,7 +992,6 @@ fn commit_state_marks_content_integrated_commits() -> Result<()> { a6588cf E state=local 4827d2f C state=local 3d3bfa7 B state=integrated -d8d0970 D state=local f5b02d3 A state=integrated "#]] ); @@ -947,7 +1018,6 @@ fn commit_state_marks_historically_integrated_commits() -> Result<()> { 972cf74 E state=local 9e74c75 C state=local ffb801b B state=integrated -d6a7004 D state=local 448b195 A state=integrated "#]] ); @@ -975,8 +1045,10 @@ fn integration_status_marks_partially_integrated_multi_branch_stack() -> Result< # Stack 0 refs/heads/A push=CompletelyUnpushed combined=UnpushedCommitsRequiringForce remote=- refs/heads/C push=Integrated combined=Integrated remote=- +refs/heads/main push=UnpushedCommitsRequiringForce combined=UnpushedCommitsRequiringForce remote=refs/remotes/origin/main # Stack 1 refs/heads/B push=CompletelyUnpushed combined=UnpushedCommitsRequiringForce remote=- +refs/heads/main push=UnpushedCommitsRequiringForce combined=UnpushedCommitsRequiringForce remote=refs/remotes/origin/main # Stack 0 44c9428 add A1 state=local @@ -1008,8 +1080,10 @@ fn integration_status_marks_fully_integrated_multi_branch_stack() -> Result<()> # Stack 0 refs/heads/A push=Integrated combined=Integrated remote=- refs/heads/C push=Integrated combined=Integrated remote=- +refs/heads/main push=UnpushedCommitsRequiringForce combined=UnpushedCommitsRequiringForce remote=refs/remotes/origin/main # Stack 1 refs/heads/B push=CompletelyUnpushed combined=UnpushedCommitsRequiringForce remote=- +refs/heads/main push=UnpushedCommitsRequiringForce combined=UnpushedCommitsRequiringForce remote=refs/remotes/origin/main # Stack 0 44c9428 add A1 state=integrated @@ -1177,7 +1251,7 @@ d5d3a92 unique local tip state=local /// workspace commit: no stacks are produced. #[test] fn workspace_branch_without_managed_commit() -> Result<()> { - let (repo, detailed) = detailed("workspace-without-managed-commit", None)?; + let (repo, detailed) = detailed("workspace-without-managed-commit", None, &[])?; snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" @@ -1187,6 +1261,20 @@ fn workspace_branch_without_managed_commit() -> Result<()> { "#]] ); - snapbox::assert_data_eq!(render(&detailed), snapbox::str!["(no stacks)"]); + snapbox::assert_data_eq!( + render(&detailed), + snapbox::str![[r#" +# Stack 0 +โ—Ž 0 refs/heads/gitbutler/workspace +โ— 1 1b78c63 just a normal commit +โ—Ž 2 refs/heads/main +โ— 3 4d41a5c one +โ— 4 965998b base + linear ref=0 rows=[0, 1] + linear ref=2 rows=[2, 3, 4] + reference ref=0 rows=[0, 1] + reference ref=2 rows=[2, 3, 4] +"#]] + ); Ok(()) } diff --git a/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/mod.rs b/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/mod.rs index da2d54776bb..02f244abeaf 100644 --- a/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/mod.rs +++ b/crates/but-workspace/tests/workspace/ref_info/with_workspace_commit/mod.rs @@ -883,6 +883,18 @@ RefInfo { commits_outside: None, metadata: Branch, push_status: NothingToPush, + base: "None", + }, + ref_info::ui::Segment { + ref_name: "โ–บA", + remote_tracking_ref_name: "refs/remotes/origin/A", + commits: [], + commits_on_remote: [ + Commit(059cc4f, "Merge branch \'B-on-A\' into new-origin-A\n"), + ], + commits_outside: None, + metadata: Branch, + push_status: UnpushedCommitsRequiringForce, base: "0ee3a9e", }, ], diff --git a/crates/but/src/command/branch/update.rs b/crates/but/src/command/branch/update.rs index 7bfd9eb718c..1323d5df613 100644 --- a/crates/but/src/command/branch/update.rs +++ b/crates/but/src/command/branch/update.rs @@ -343,7 +343,7 @@ fn format_preview(branch_ref: &FullNameRef, result: &IntegrateBranchResult) -> S let matching_segment = result .workspace .graph_workspace - .stacks + .display_stacks()? .iter() .find_map(|stack| { stack.reference_segments.iter().find_map(|segment| { diff --git a/crates/but/src/command/config.rs b/crates/but/src/command/config.rs index 8a76adc9e4c..6a4fe222aff 100644 --- a/crates/but/src/command/config.rs +++ b/crates/but/src/command/config.rs @@ -1825,7 +1825,7 @@ async fn target_config( Some(new_branch) => { // refuse to run if there are any applied branches. if so, ask user to unapply first. let (guard, _, ws, _) = ctx.workspace_and_db()?; - if !ws.stacks.is_empty() { + if !ws.display_stacks()?.is_empty() { // list the applied branches if let Some(out) = out.for_human() { writeln!( @@ -1834,7 +1834,7 @@ async fn target_config( t.important .paint("\nThe following branches are currently applied:\n") )?; - ws.stacks.iter().for_each(|stack| { + ws.display_stacks()?.iter().for_each(|stack| { { writeln!( out, diff --git a/crates/but/src/command/legacy/squash2.rs b/crates/but/src/command/legacy/squash2.rs index 008f9193835..e35314405e0 100644 --- a/crates/but/src/command/legacy/squash2.rs +++ b/crates/but/src/command/legacy/squash2.rs @@ -1189,7 +1189,7 @@ fn resolve_commits_on_branch( ) -> CliResult<(FullName, Vec)> { let branch_name = branch.resolve_local_branch_name()?; let (_, segment) = ws.try_find_segment_and_stack_by_refname(branch_name.as_ref())?; - let commits_in_segment = segment.commits.iter().map(|commit| commit.id).collect(); + let commits_in_segment = segment.commits.clone(); Ok((branch_name, commits_in_segment)) } diff --git a/crates/but/src/command/legacy/status/mod.rs b/crates/but/src/command/legacy/status/mod.rs index ef74fddcc5b..6902575ce11 100644 --- a/crates/but/src/command/legacy/status/mod.rs +++ b/crates/but/src/command/legacy/status/mod.rs @@ -415,7 +415,7 @@ fn build_status_context<'a>( push_statuses_by_segment_id, local_commits_by_id, remote_commits_by_id, - ws.stacks.clone(), + ws.display_stacks()?.to_vec(), resolved_target, commit_id_to_change_id, ) diff --git a/crates/but/src/command/legacy/status/tui/app/rub_mode.rs b/crates/but/src/command/legacy/status/tui/app/rub_mode.rs index 8cfb15768f1..7ecd715facf 100644 --- a/crates/but/src/command/legacy/status/tui/app/rub_mode.rs +++ b/crates/but/src/command/legacy/status/tui/app/rub_mode.rs @@ -303,8 +303,11 @@ impl App { let stack_id = { let (_guard, _, ws, _) = ctx.workspace_and_db()?; - ws.find_commit_and_containers(*commit_id) - .and_then(|(stack, _, _)| stack.id) + but_graph::workspace::find_commit_and_containers_in( + &ws.display_stacks().unwrap_or_default(), + *commit_id, + ) + .and_then(|(stack, _, _)| stack.id) }; let source = if let Some(stack_id) = stack_id diff --git a/crates/but/src/id/mod.rs b/crates/but/src/id/mod.rs index 8216e6f00e3..b4622fd6298 100644 --- a/crates/but/src/id/mod.rs +++ b/crates/but/src/id/mod.rs @@ -843,8 +843,8 @@ impl IdMap { } }; - let commit_ids = ws - .stacks + let display_stacks = ws.display_stacks()?; + let commit_ids = display_stacks .iter() .flat_map(|stack| &stack.segments) .flat_map(|segment| segment.commits.iter()) @@ -879,7 +879,11 @@ impl IdMap { }) .collect(); - Self::new(ws.stacks.clone(), hunk_assignments, commit_id_to_change_id) + Self::new( + ws.display_stacks()?.to_vec(), + hunk_assignments, + commit_id_to_change_id, + ) } } diff --git a/crates/but/src/id/parser.rs b/crates/but/src/id/parser.rs index 636c589c045..fce040adcbb 100644 --- a/crates/but/src/id/parser.rs +++ b/crates/but/src/id/parser.rs @@ -122,8 +122,11 @@ fn get_all_files_in_display_order(ctx: &mut Context, id_map: &IdMap) -> anyhow:: // First, files assigned to branches (they appear first in status display), // then uncommitted files (they appear last in status display) let (_guard, _, workspace, _) = ctx.workspace_and_db()?; - let stack_ids: Vec>> = - workspace.stacks.iter().map(|stack| stack.id).collect(); + let stack_ids: Vec>> = workspace + .display_stacks()? + .iter() + .map(|stack| stack.id) + .collect(); let mut positioned_files: Vec<(usize, &BStr, CliId)> = id_map .uncommitted_files .values() diff --git a/crates/but/src/utils/metrics.rs b/crates/but/src/utils/metrics.rs index 822c51fded8..e91f05fce42 100644 --- a/crates/but/src/utils/metrics.rs +++ b/crates/but/src/utils/metrics.rs @@ -547,7 +547,8 @@ pub fn add_workspace_shape(event: &mut Event, current_dir: &Path) { return; } let branches_per_lane: Vec = ws - .stacks + .display_stacks() + .unwrap_or_default() .iter() .map(|stack| { stack diff --git a/crates/but/src/utils/rejection.rs b/crates/but/src/utils/rejection.rs index 9b09b893416..dd7128db36e 100644 --- a/crates/but/src/utils/rejection.rs +++ b/crates/but/src/utils/rejection.rs @@ -299,12 +299,12 @@ pub fn branch_of_commit( commit_id: gix::ObjectId, prefer: Option, ) -> Option { - let ordered = ws - .stacks + let display_stacks = ws.display_stacks().ok()?; + let ordered = display_stacks .iter() .filter(|stack| prefer.is_none() || stack.id == prefer) .chain( - ws.stacks + display_stacks .iter() .filter(|stack| prefer.is_some() && stack.id != prefer), ); diff --git a/crates/but/tests/but/command/branch/apply.rs b/crates/but/tests/but/command/branch/apply.rs index 7e871ed059c..43464991332 100644 --- a/crates/but/tests/but/command/branch/apply.rs +++ b/crates/but/tests/but/command/branch/apply.rs @@ -161,7 +161,7 @@ workspace_ref_created=false snapbox::assert_data_eq!( env.git_log(), snapbox::str![[r#" -* 9d5d9e5 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* a7a8e9d (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ | * 9f9d5a6 (feature-branch) Add feature * | 9477ae7 (A) add A @@ -241,7 +241,7 @@ fn local_branch_with_json_output() { snapbox::assert_data_eq!( env.git_log(), snapbox::str![[r#" -* 9d5d9e5 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* a7a8e9d (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ | * 9f9d5a6 (feature-branch) Add feature * | 9477ae7 (A) add A @@ -324,7 +324,7 @@ Applied remote branch 'origin/remote-feature' to workspace snapbox::assert_data_eq!( env.git_log(), snapbox::str![[r#" -* 1bb7daf (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* c2021d8 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ | * ba02e5f (origin/remote-feature, remote-feature) Add remote feature * | 9477ae7 (A) add A @@ -375,7 +375,7 @@ Applied remote branch 'origin/remote-feature' to workspace snapbox::assert_data_eq!( env.git_log(), snapbox::str![[r#" -* 1bb7daf (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* c2021d8 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ | * ba02e5f (origin/remote-feature, remote-feature) Add remote feature * | 9477ae7 (A) add A @@ -435,7 +435,7 @@ Applied remote branch 'origin/remote-feature' to workspace snapbox::assert_data_eq!( env.git_log(), snapbox::str![[r#" -* 1bb7daf (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* c2021d8 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ | * ba02e5f (upstream/remote-feature, origin/remote-feature, remote-feature) Add remote feature * | 9477ae7 (A) add A @@ -555,7 +555,7 @@ Applied branch 'feature-2' to workspace snapbox::assert_data_eq!( env.git_log(), snapbox::str![[r#" -*-. 7044ae9 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +*-. 088a75a (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ \ | | * 4e81b31 (feature-2) Add feature 2 | * | 9c2fe5c (feature-1) Add feature 1 diff --git a/crates/but/tests/but/command/branch/unapply.rs b/crates/but/tests/but/command/branch/unapply.rs index d655e543dbd..1f0dda39f72 100644 --- a/crates/but/tests/but/command/branch/unapply.rs +++ b/crates/but/tests/but/command/branch/unapply.rs @@ -32,7 +32,7 @@ fn single_branch() { snapbox::assert_data_eq!( env.git_log(), snapbox::str![[r#" -* 9d5d9e5 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* a7a8e9d (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ | * 9f9d5a6 (feature-branch) Add feature * | 9477ae7 (A) add A @@ -59,7 +59,7 @@ Unapplied stack with branches 'feature-branch' from workspace env.git_log(), snapbox::str![[r#" * 9f9d5a6 (feature-branch) Add feature -| * 0bbfbfd (HEAD -> gitbutler/workspace) GitButler Workspace Commit +| * e92467e (HEAD -> gitbutler/workspace) GitButler Workspace Commit | * 9477ae7 (A) add A |/ * 0dc3733 (origin/main, origin/HEAD, main, gitbutler/target) add M @@ -101,7 +101,7 @@ fn unapply_with_json_output() { env.git_log(), snapbox::str![[r#" * 9f9d5a6 (feature-branch) Add feature -| * 0bbfbfd (HEAD -> gitbutler/workspace) GitButler Workspace Commit +| * e92467e (HEAD -> gitbutler/workspace) GitButler Workspace Commit | * 9477ae7 (A) add A |/ * 0dc3733 (origin/main, origin/HEAD, main, gitbutler/target) add M @@ -237,7 +237,7 @@ fn unapply_remote_tracking_branch() { snapbox::assert_data_eq!( env.git_log(), snapbox::str![[r#" -* 1bb7daf (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* c2021d8 (HEAD -> gitbutler/workspace) GitButler Workspace Commit |\ | * ba02e5f (origin/remote-feature, remote-feature) Add remote feature * | 9477ae7 (A) add A @@ -262,7 +262,7 @@ Unapplied stack with branches 'remote-feature' from workspace snapbox::assert_data_eq!( env.git_log(), snapbox::str![[r#" -* 0bbfbfd (HEAD -> gitbutler/workspace) GitButler Workspace Commit +* 517354c (HEAD -> gitbutler/workspace) GitButler Workspace Commit * 9477ae7 (A) add A | * ba02e5f (origin/remote-feature, remote-feature) Add remote feature |/ diff --git a/crates/but/tests/but/command/move.rs b/crates/but/tests/but/command/move.rs index 6a2e7280ecf..1a7cbcbf07c 100644 --- a/crates/but/tests/but/command/move.rs +++ b/crates/but/tests/but/command/move.rs @@ -1201,6 +1201,7 @@ fn setup_single_stack_metadata(env: &Sandbox, branch_names: &[&str]) -> anyhow:: Ok(WorkspaceStackBranch { ref_name: format!("refs/heads/{branch_name}").try_into()?, archived: false, + parents: None, }) }) .collect::>>()?, diff --git a/crates/but/tests/but/command/pull.rs b/crates/but/tests/but/command/pull.rs index e52a2a5f76d..55588f51776 100644 --- a/crates/but/tests/but/command/pull.rs +++ b/crates/but/tests/but/command/pull.rs @@ -716,6 +716,7 @@ fn setup_single_stack_metadata_at_target( .map(|branch_name| WorkspaceStackBranch { ref_name: r(&format!("refs/heads/{branch_name}")).to_owned(), archived: false, + parents: None, }) .collect(), workspacecommit_relation: WorkspaceCommitRelation::Merged, diff --git a/crates/but/tests/but/command/status.rs b/crates/but/tests/but/command/status.rs index 0fefa670783..af0dbf2ece4 100644 --- a/crates/but/tests/but/command/status.rs +++ b/crates/but/tests/but/command/status.rs @@ -662,8 +662,7 @@ Applied remote branch 'origin/document-but-pr-skill' to workspace โ”Šโ•ญโ”„do [document-but-pr-skill] (merged upstream) (no commits) โ”œโ•ฏ โ”Š -โ”Šโ— 55165db (upstream) โซ 1 commit -โ”œโ•ฏ 55165db (common base) 2000-01-02 merge document-but-pr-skill +โ”ด 55165db (common base) 2000-01-02 merge document-but-pr-skill Hint: branches marked `(merged upstream)` have landed; run `but pull` to remove them, or start new work on another branch diff --git a/crates/gitbutler-branch-actions/tests/branch-actions/driverless.rs b/crates/gitbutler-branch-actions/tests/branch-actions/driverless.rs index 5e013922c15..08eb8d19261 100644 --- a/crates/gitbutler-branch-actions/tests/branch-actions/driverless.rs +++ b/crates/gitbutler-branch-actions/tests/branch-actions/driverless.rs @@ -171,6 +171,7 @@ fn write_workspace_metadata(repo: &gix::Repository, stacks: &[StackSpec<'_>]) -> Ok(WorkspaceStackBranch { ref_name: gix::refs::FullName::try_from(format!("refs/heads/{name}"))?, archived: false, + parents: None, }) }) .collect::>>()?, diff --git a/crates/gitbutler-branch-actions/tests/branch-actions/virtual_branches/set_base_branch.rs b/crates/gitbutler-branch-actions/tests/branch-actions/virtual_branches/set_base_branch.rs index 1a2bd83f9a9..d6d19e42d17 100644 --- a/crates/gitbutler-branch-actions/tests/branch-actions/virtual_branches/set_base_branch.rs +++ b/crates/gitbutler-branch-actions/tests/branch-actions/virtual_branches/set_base_branch.rs @@ -346,7 +346,7 @@ mod behind_count { .unwrap(); let outcome = but_workspace::branch::apply( "refs/heads/C".try_into().unwrap(), - workspace.clone(), + &workspace, &repo, &mut meta, but_workspace::branch::apply::Options::default(), diff --git a/crates/gitbutler-edit-mode/src/lib.rs b/crates/gitbutler-edit-mode/src/lib.rs index ded72bb597e..ac4a51022f1 100644 --- a/crates/gitbutler-edit-mode/src/lib.rs +++ b/crates/gitbutler-edit-mode/src/lib.rs @@ -227,7 +227,11 @@ fn workspace_from_workspace_ref(ctx: &Context) -> Result { fn ensure_stack_in_workspace(ctx: &Context, stack_id: StackId) -> Result<()> { #[allow(deprecated)] - workspace_from_workspace_ref(ctx)?.try_find_stack_by_id(stack_id)?; + workspace_from_workspace_ref(ctx)? + .segment_graph + .stack_by_id(stack_id) + .map(|_| ()) + .with_context(|| format!("Couldn't find stack with id {stack_id:?} in workspace"))?; Ok(()) } @@ -312,7 +316,7 @@ pub(crate) fn save_and_return_to_workspace(ctx: &Context, perm: &mut RepoExclusi .stored_target_commit_id() .context("failed to get target base oid")?; let old_head_oids = old_workspace_projection - .stacks + .display_stacks()? .iter() .map(|stack| stack.tip_skip_empty().unwrap_or(old_target_base_oid)) .collect::>(); diff --git a/crates/gitbutler-edit-mode/tests/edit_mode.rs b/crates/gitbutler-edit-mode/tests/edit_mode.rs index a4250076509..08c7a0314e1 100644 --- a/crates/gitbutler-edit-mode/tests/edit_mode.rs +++ b/crates/gitbutler-edit-mode/tests/edit_mode.rs @@ -49,6 +49,7 @@ fn seed_metadata(repo: &gix::Repository) -> Result<()> { branches: vec![WorkspaceStackBranch { ref_name: "refs/heads/branchy".try_into()?, archived: false, + parents: None, }], workspacecommit_relation: WorkspaceCommitRelation::Merged, }); @@ -66,7 +67,7 @@ fn seed_metadata(repo: &gix::Repository) -> Result<()> { } fn stack_id(ws: &but_graph::Workspace) -> Result { - ws.stacks + ws.display_stacks()? .first() .context("expected workspace stack")? .id diff --git a/crates/gitbutler-operating-modes/src/lib.rs b/crates/gitbutler-operating-modes/src/lib.rs index 56936aac4b2..dc02773cc2a 100644 --- a/crates/gitbutler-operating-modes/src/lib.rs +++ b/crates/gitbutler-operating-modes/src/lib.rs @@ -186,7 +186,7 @@ fn worktree_conflicts_with_workspace( gix_repo: &gix::Repository, ) -> Result> { let (_repo, ws, _db) = ctx.workspace_and_db_with_perm(perm)?; - if ws.target_ref.is_none() || ws.stacks.is_empty() { + if ws.target_ref.is_none() || ws.display_stacks().map_or(true, |s| s.is_empty()) { // Nothing to conflict return Ok(vec![]); } diff --git a/crates/gitbutler-stack/tests/stack.rs b/crates/gitbutler-stack/tests/stack.rs index 30024011856..feebe970c43 100644 --- a/crates/gitbutler-stack/tests/stack.rs +++ b/crates/gitbutler-stack/tests/stack.rs @@ -342,6 +342,7 @@ fn seed_metadata(repo: &gix::Repository, name: &str) -> Result<()> { branches: vec![WorkspaceStackBranch { ref_name: "refs/heads/first_branch".try_into()?, archived: false, + parents: None, }], workspacecommit_relation: WorkspaceCommitRelation::Merged, }); @@ -350,6 +351,7 @@ fn seed_metadata(repo: &gix::Repository, name: &str) -> Result<()> { branches: vec![WorkspaceStackBranch { ref_name: "refs/heads/virtual".try_into()?, archived: false, + parents: None, }], workspacecommit_relation: WorkspaceCommitRelation::Merged, }); From 80e5d3e0c4f7d47a0e12f0fd9d9a735761cc4735 Mon Sep 17 00:00:00 2001 From: Mattias Granlund Date: Tue, 21 Jul 2026 15:13:05 +0200 Subject: [PATCH 11/11] fix: re-hang riders when a reference slides under its own rider Placing a reference below a branch that sits alone above the same spot (commit or move --below an empty branch) built a below-chain cycle and died on the well-formedness check: the twin-key rule first stacks the newcomer ON the empty branch's group, and the repair placement then joins the branch into the rider group extract left attached to it - a group attached to its own member. extract now returns the spot the moved reference vacated, and place re-hangs any rider group the reference is about to slide under onto that vacated spot before insertion, so the pair lands as one correctly ordered group instead of a cycle. Covered by the previously failing but-2 tests (commit2, move2); editor rings and the three op fuzzers (450 seeds each) stay green. Co-Authored-By: Claude Fable 5 --- .../src/graph_rebase/graph_editor.rs | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/crates/but-rebase/src/graph_rebase/graph_editor.rs b/crates/but-rebase/src/graph_rebase/graph_editor.rs index c4d3974767d..a7a71e81a78 100644 --- a/crates/but-rebase/src/graph_rebase/graph_editor.rs +++ b/crates/but-rebase/src/graph_rebase/graph_editor.rs @@ -523,8 +523,8 @@ impl GraphEditor { below: Option, ) { let carry = self.classify(on, entering); - self.extract(entry); - self.place(entry, on, carry, below); + let vacated = self.extract(entry); + self.place(entry, on, carry, below, vacated); self.set_ambiguous(entry, ambiguous); } @@ -577,8 +577,8 @@ impl GraphEditor { }; let carry = self.layout[&key][g].carry.clone(); let ambiguous = self.refs[mate.0].ambiguous; - self.extract(entry); - self.place(entry, key, carry, below); + let vacated = self.extract(entry); + self.place(entry, key, carry, below, vacated); self.set_ambiguous(entry, ambiguous); } @@ -594,8 +594,8 @@ impl GraphEditor { } let below = self.below_of(entry); let carry = self.carry_of(entry).cloned().unwrap_or(GroupCarry::All); - self.extract(entry); - self.place(entry, onto, carry, below); + let vacated = self.extract(entry); + self.place(entry, onto, carry, below, vacated); } /// Re-hang `entry` onto `below` โ€” an adjacency statement only; its key, carry, and @@ -608,8 +608,8 @@ impl GraphEditor { return; } let carry = self.carry_of(entry).cloned().unwrap_or(GroupCarry::All); - self.extract(entry); - self.place(entry, on, carry, below); + let vacated = self.extract(entry); + self.place(entry, on, carry, below, vacated); } /// Point a dead reference's kept position at `on` โ€” the bare pointer that stale @@ -622,8 +622,8 @@ impl GraphEditor { "retained positions belong to dead references" ); self.splice(entry); - self.extract(entry); - self.place(entry, on, GroupCarry::None, None); + let vacated = self.extract(entry); + self.place(entry, on, GroupCarry::None, None, vacated); self.set_ambiguous(entry, false); } @@ -696,12 +696,15 @@ impl GraphEditor { /// group split off as their own group attached to `entry` (they keep sitting on it, /// wherever it goes next), and groups attached to `entry` stay attached. The caller /// re-places the entry immediately. - fn extract(&mut self, entry: RefIndex) { - let Some((key, g, i)) = self.locate(entry) else { - return; - }; + fn extract(&mut self, entry: RefIndex) -> Option { + let (key, g, i) = self.locate(entry)?; let name = self.name_of(entry).clone(); let groups = self.layout.get_mut(&key).expect("just located"); + let vacated = if i > 0 { + Some(groups[g].members[i - 1].clone()) + } else { + groups[g].attach.clone() + }; let above = groups[g].members.split_off(i + 1); groups[g].members.pop(); if !above.is_empty() { @@ -718,6 +721,7 @@ impl GraphEditor { if groups.is_empty() { self.layout.remove(&key); } + vacated } /// Put the (extracted or fresh) `entry` into the table at `key` via the shared @@ -728,10 +732,22 @@ impl GraphEditor { key: PickIndex, carry: GroupCarry, attach: Option, + vacated: Option, ) { let carry = self.normalize_carry(key, carry); let name = self.name_of(entry).clone(); let attach = attach.map(|b| self.name_of(b).clone()); + // The entry may be sliding UNDER one of its own riders: a group still attached to + // it (extract leaves riders hanging on the moved entry) that contains the new + // below. Left alone the attach chain closes on itself; the rider takes the spot + // the entry vacated instead. + if let (Some(b), Some(groups)) = (attach.as_ref(), self.layout.get_mut(&key)) { + for group in groups.iter_mut() { + if group.attach.as_ref() == Some(&name) && group.members.contains(b) { + group.attach = vacated.clone(); + } + } + } but_graph::ref_layout::place_in_groups( self.layout.entry(key).or_default(), name,