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 e062a1a64a3..341b8262c19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1340,14 +1340,12 @@ dependencies = [ "boolean-enums", "bstr", "but-core", - "but-graph", "but-meta", "but-testsupport", "gitbutler-reference", "gix", - "itertools", - "petgraph", "snapbox", + "termtree", "tracing", ] @@ -1556,7 +1554,6 @@ dependencies = [ "but-testsupport", "gitbutler-reference", "gix", - "petgraph", "sapling-renderdag", "schemars 1.2.1", "serde", @@ -1656,6 +1653,7 @@ dependencies = [ "gix", "gix-testtools", "regex", + "sapling-renderdag", "shell-words", "snapbox", "temp-env", @@ -1686,6 +1684,7 @@ dependencies = [ "but-core", "but-ctx", "but-db", + "but-graph", "but-oplog", "but-rebase", "but-testsupport", @@ -7651,7 +7650,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 775f1392dcd..d56256e67b4 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..50a827a4204 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, @@ -175,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")?; @@ -195,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 2265e57e7d1..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())); @@ -933,11 +935,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 @@ -956,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 }; @@ -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,24 +1565,13 @@ 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 but_workspace::branch::move_branch::Outcome { - rebase, - ws_meta, - new_tip, - branch_stack_order, - } = but_workspace::branch::move_branch(editor, subject_branch, target_branch)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; + 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( - 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)) }, @@ -1650,15 +1634,12 @@ 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 but_workspace::branch::move_branch::Outcome { - rebase, ws_meta, .. - } = but_workspace::branch::tear_off_branch(editor, subject_branch, None)?; + let editor = Editor::create(ws.commit_graph(), ws.project_meta(), &mut meta, &repo)?; + let outcome = + 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, - )?, + workspace: branch_workspace_from_rebase(&mut ws, outcome, &repo, dry_run, &db)?, }) }, ) @@ -1692,54 +1673,36 @@ where } fn branch_workspace_from_rebase( - mut rebase: SuccessfulRebase<'_, '_, M>, - ws_meta: Option, - new_tip: Option<&gix::refs::FullName>, - branch_stack_order: Option<&[gix::refs::FullName]>, + workspace: &mut but_graph::Workspace, + 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 workspace = rebase - .overlayed_graph_with_workspace_overrides(entrypoint, branch_stack_order)? - .into_workspace()?; + let overlay = rebase.rebase_overlay_with_workspace_overrides( + entrypoint, + outcome.branch_stack_order.as_deref(), + )?; let (repo, meta) = rebase.repo_and_meta_mut(); - return WorkspaceState::from_workspace_with_db( - &workspace, - 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()) { - let mut md = materialized.meta.workspace(ref_name)?; - *md = ws_meta; - md.set_project_meta(materialized.workspace.graph.project_meta.clone()); - materialized.meta.set_workspace(&md)?; + let preview = workspace.redo(repo, meta, overlay)?; + return WorkspaceState::from_workspace_with_db(&preview, meta, repo, replaced_commits, db); } + let applied = outcome.apply(workspace, repo)?; WorkspaceState::from_workspace_with_db( - materialized.workspace, - materialized.meta, + workspace, + applied.meta, repo, - materialized.history.commit_mappings(), + applied.commit_mappings, db, ) } 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..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" @@ -197,8 +199,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, }) }) @@ -216,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(()) } @@ -245,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 1f66816f52c..6ad0cf51e0c 100644 --- a/crates/but-api/src/legacy/virtual_branches.rs +++ b/crates/but-api/src/legacy/virtual_branches.rs @@ -5,16 +5,13 @@ 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; 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}; @@ -65,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()); @@ -77,7 +77,7 @@ pub fn create_virtual_branch( .as_ref() .and_then(|meta| meta.review.pull_request); - let out = StackEntryNoOpt { + StackEntryNoOpt { id: stack .id .context("BUG: all new stacks are created with an ID")?, @@ -90,10 +90,7 @@ pub fn create_virtual_branch( tip, order: Some(stack_idx), is_checked_out: false, - }; - - *ws = new_ws.into_owned(); - out + } }; Ok(stack_entry) } @@ -121,7 +118,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, @@ -130,9 +127,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)?; @@ -290,7 +288,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(()) @@ -350,24 +351,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::{ @@ -449,6 +432,7 @@ mod tests { ref_name: gix::refs::FullName::try_from(format!("refs/heads/{name}")) .expect("valid test ref name"), archived: false, + parents: None, }], } } @@ -576,7 +560,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()?; @@ -593,7 +578,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) ); @@ -652,8 +638,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 91f0eaec7cf..35ebf82dd13 100644 --- a/crates/but-api/src/workspace.rs +++ b/crates/but-api/src/workspace.rs @@ -15,7 +15,7 @@ use but_core::{ use but_error::AnyhowContextExt as _; 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, @@ -205,9 +205,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 @@ -429,7 +427,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![]); }; @@ -568,19 +566,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, @@ -589,8 +591,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) { @@ -601,7 +604,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 80b78126e5d..c3233f23f73 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,19 +191,9 @@ 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( - NodeIndex(1), - ), is_managed_ref: false, is_managed_commit: false, ancestor_workspace_commit: None, @@ -252,12 +241,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[🌳] on 5374caf {1} + └── :new-branch[🌳] -"]] +"#]] ); #[cfg(feature = "graph-workspace")] @@ -307,10 +296,11 @@ RefInfo { id: Some( 00000000-0000-0000-0000-000000000001, ), - base: None, + base: Some( + Sha1(5374caf21933aee76b72bad8d6e30949c7a30e04), + ), segments: [ ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "►new-branch[🌳]", remote_tracking_ref_name: "None", commits: [], @@ -318,7 +308,7 @@ RefInfo { commits_outside: None, metadata: "None", push_status: CompletelyUnpushed, - base: "None", + base: "5374caf", }, ], }, @@ -328,19 +318,9 @@ 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( - 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/commit/mod.rs b/crates/but-core/src/commit/mod.rs index 86a290966c7..6a3d48bbdce 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 ed2e9c9b1a5..ad947d1daa8 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 { @@ -743,7 +611,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, @@ -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-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/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/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..745fbbd4136 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,26 +100,26 @@ 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 { 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::(), @@ -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-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-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..5d3ae887bac 100644 --- a/crates/but-graph/Cargo.toml +++ b/crates/but-graph/Cargo.toml @@ -14,23 +14,20 @@ 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 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/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/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..4c55beca55c --- /dev/null +++ b/crates/but-graph/src/build/layout.rs @@ -0,0 +1,672 @@ +//! 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)); + 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 +/// 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..93e913405a5 --- /dev/null +++ b/crates/but-graph/src/build/materialize.rs @@ -0,0 +1,338 @@ +//! 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 — 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) + || 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; + } + 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. + // 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 + // `►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..2355d2ed7df --- /dev/null +++ b/crates/but-graph/src/build/mod.rs @@ -0,0 +1,873 @@ +//! 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 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> { + 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, + WorkspaceAnchors { + tip: 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 Ok(Some((managed_cg, ctx))); + } + 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, + )?; + Ok(Some((cg, ctx))) +} + +/// 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, + WorkspaceAnchors { + tip: ws_commit, + entrypoint: 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, + WorkspaceAnchors { + tip: 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, + anchors: WorkspaceAnchors, + 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, 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)?; + let (mut cg, ctx, entrypoint_reached) = assemble( + cg, + overlay_repo, + overlay_meta, + 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 +/// 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, + WorkspaceAnchors { + tip: head_tip, + entrypoint: head_tip, + }, + entrypoint_ref, + None, + inputs, + project_meta, + options, + )?; + 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 +/// 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>, + anchors: WorkspaceAnchors, + 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: anchors.tip, + entrypoint: anchors.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 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 +/// 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..08f8b277652 --- /dev/null +++ b/crates/but-graph/src/build/plan.rs @@ -0,0 +1,866 @@ +//! 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( + 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( + 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))) + { + name_of.remove(&lb); + anonymous_bases.insert(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) + // 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) + { + 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..71ccb75beed --- /dev/null +++ b/crates/but-graph/src/build/remote_segments.rs @@ -0,0 +1,406 @@ +//! 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 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() { + 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)); + } + } + + 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) = owned_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); + } + for &h in &store.segments[sidx].commits { + owned_commit_sidx.entry(cg.id_at(h)).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..19d59f42f46 --- /dev/null +++ b/crates/but-graph/src/build/remotes.rs @@ -0,0 +1,206 @@ +//! 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) + { + // 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..136b2d0b3ae --- /dev/null +++ b/crates/but-graph/src/build/segment_data.rs @@ -0,0 +1,1231 @@ +//! 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>, + /// 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, + /// 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. + /// 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<(i64, usize, usize)> = Vec::new(); + start.push(0); + for sidx in &self.segments { + 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()); + 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 { + 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)), + }; + 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(|&(parent_number, ci, _)| (parent_number, ci)); + 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(); + 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(); + 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, + 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, + 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 + && 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 { + // 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 let Some(owner) = owner + && decision.rejoin != workspace_commit + && store.segments[owner].sibling_segment_id.is_none() + { + store.segments[owner].sibling_segment_id = Some(sidx); + } + } + 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, + ); +} + +/// 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, + stack_branches: Option<&[Vec]>, + 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() { + // 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(|| { + 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() + { + // 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, + anchor, + members, + remote_tracking, + *into_owning_chain, + hang, + ); + } + from_sidx = Some(anchor); + } + } +} + +/// 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, + anchor: usize, + empties: &[gix::refs::FullName], + remote_tracking: &HashMap, + into_owning_chain: bool, + hang: SpliceHang<'_>, +) { + 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 { + // 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)) + .collect() + } else { + vec![from_sidx] + }; + let mut redirected = false; + for source in redirect_sources { + redirected |= store.retarget_edges(source, anchor, top) > 0; + } + if !redirected { + 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 + }; + match (route, hang) { + (Some(route), _) => { + store.retarget_edges(route, anchor, 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), + } + } + } + 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; + } + 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, + 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/commit_graph.rs b/crates/but-graph/src/commit_graph.rs new file mode 100644 index 00000000000..1918980e416 --- /dev/null +++ b/crates/but-graph/src/commit_graph.rs @@ -0,0 +1,1079 @@ +//! 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, + /// 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, + /// 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; + outcome.apply_posthoc_flags(); + 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; + outcome.apply_posthoc_flags(); + 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(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 { + 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() + } + + /// 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 + .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); + } + // 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 + /// 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/debug.rs b/crates/but-graph/src/debug.rs index 7806da6693f..288b1345312 100644 --- a/crates/but-graph/src/debug.rs +++ b/crates/but-graph/src/debug.rs @@ -1,22 +1,153 @@ -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); + // 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 } => { + 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)?; + } + } + // 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)?; + anon.apply(&mut remote)?; + 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) + } +} + +/// 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().cloned().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 +163,274 @@ 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)?; + + 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:?}'"); } - if let Some(rn) = node.remote_tracking_ref_name.as_mut() { - anon(rn)?; + } + Ok(()) + } +} + +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)?; } - for ri in node.commits.iter_mut().flat_map(|c| c.refs.iter_mut()) { - anon(&mut ri.ref_name)?; + } + 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(SegmentMetadata::Workspace(md)) = node.metadata.as_mut() { - for rn in md + 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(rn)?; + anon.apply(rn)?; } } } - 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(), - ) - } - - 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(", ") - ) + // 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)?; + } } - ) - } - - /// 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() - ) + 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(()) } +} - /// 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 +451,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 +471,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/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/overlay.rs b/crates/but-graph/src/init/overlay.rs deleted file mode 100644 index 2f3c13b25d7..00000000000 --- a/crates/but-graph/src/init/overlay.rs +++ /dev/null @@ -1,535 +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) - .map(|result| result.map(Cow::Owned)) - } - - 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 { - 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/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/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/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/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..6716ef4033e 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. @@ -65,14 +65,14 @@ impl Workspace { first_parent: FirstParent, ) -> Result> { let mut heads = self - .stacks + .display_stacks()? .iter() .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/api/mod.rs b/crates/but-graph/src/projection/api/mod.rs new file mode 100644 index 00000000000..e878d615484 --- /dev/null +++ b/crates/but-graph/src/projection/api/mod.rs @@ -0,0 +1,376 @@ +use anyhow::Context; +use bstr::BStr; +use but_core::{RefMetadata, extract_remote_name_and_short_name}; +use tracing::instrument; + +use crate::{ + Workspace, + workspace::{Segment, SegmentStack, WorkspaceKind}, +}; + +mod queries; +pub use queries::StackTip; +#[cfg(feature = "legacy")] +pub use queries::legacy::HeadStatus; + +/// Lifecycle +impl Workspace { + /// Redo the graph traversal with the same settings as before, but use the latest + /// data from `repo`, `meta` and `project_meta` to do it. + /// 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.ctx.project_meta.clone()` to deliberately keep the current one, + /// e.g. in the middle of an operation. + #[instrument( + name = "Workspace::refresh_from_head", + level = "debug", + skip_all, + err(Debug) + )] + pub fn refresh_from_head( + &mut self, + repo: &gix::Repository, + meta: &impl RefMetadata, + project_meta: but_core::ref_metadata::ProjectMeta, + ) -> anyhow::Result<()> { + *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(()) + } + + /// 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; + } + } +} + +/// Query +impl Workspace { + /// Return `true` if the workspace has workspace metadata associated with it. + /// This is relevant when creating references for example. + pub fn has_metadata(&self) -> bool { + self.metadata.is_some() + } + + /// 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.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() + .map_or("".into(), |rn| rn.as_bstr()) + } +} + +/// Utilities +impl Workspace { + /// Return the name of the remote most closely associated with this workspace. + /// In order, we try: + /// - The remote name of the [Self::target_ref]. + /// - The remote name configured in [workspace metadata](Self::metadata). + /// + /// The caller *may* consider falling back to [`gix::Repository::remote_default_name()`], + /// but beware that one should handle ambiguity if there are more than one remotes. + pub fn remote_name(&self) -> Option { + if let Some(tr) = self.target_ref.as_ref() { + // TODO: should we rather get remote configuration from the repository? + let remote_names = self + .ctx + .symbolic_remote_names + .iter() + .map(|name| name.as_str().into()) + .collect(); + extract_remote_name_and_short_name(tr.ref_name.as_ref(), &remote_names) + .map(|(remote_name, _)| remote_name) + } else { + self.ctx.project_meta.push_remote.clone() + } + } + + /// Return the resolved target commit ID for use as a base for new branches. + /// + /// 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 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(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 (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, + /// whose commit is also returned as `target-commit-id`. + pub fn merge_base_with_target_branch( + &self, + commit_to_merge: impl Into, + ) -> Option<(gix::ObjectId, gix::ObjectId)> { + let commit_to_merge = commit_to_merge.into(); + 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. + /// 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 { + // 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. + + !(self.frame.entry_inside && self.frame.kind.has_managed_ref()) + } + + /// 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() + .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))) + } + + /// Whether the entry "marks" the segment-graph stack at `stack_idx`, replicating the display + /// marker's exact semantics (`mark_entrypoint_segments`): 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)), + } + } + + /// 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) + } + + /// Return `true` if `name` is contained in the workspace as segment. + pub fn refname_is_segment(&self, name: &gix::refs::FullNameRef) -> bool { + self.find_segment_and_stack_by_refname(name).is_some() + } + + /// Return `true` if `name` is in the ancestry of the workspace entrypoint, and is IN the workspace as well. + pub fn is_reachable_from_entrypoint(&self, name: &gix::refs::FullNameRef) -> bool { + if self.ref_name().filter(|_| self.is_entrypoint()) == Some(name) { + return true; + } + if self.is_entrypoint() { + self.refname_is_segment(name) + } else { + // 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). + + 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))) + }) + }) + } + } + + /// 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<(&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])) + } + + /// 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<(&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<(&SegmentStack, &Segment)> { + self.find_segment_and_stack_by_refname(name) + .with_context(|| { + format!( + "Couldn't find any stack that contained the branch named '{}'", + name.shorten() + ) + }) + } +} + +/// Debugging +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 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 } => ( + ref_debug_string(ref_info.ref_name.as_ref(), ref_info.worktree.as_ref()), + "🏘️", + ), + WorkspaceKind::ManagedMissingWorkspaceCommit { ref_info } => ( + ref_debug_string(ref_info.ref_name.as_ref(), ref_info.worktree.as_ref()), + "🏘️⚠️", + ), + WorkspaceKind::AdHoc => ( + self.tip_ref_info.as_ref().map_or("DETACHED".into(), |ri| { + ref_debug_string(ri.ref_name.as_ref(), ri.worktree.as_ref()) + }), + "⌂", + ), + }; + let target = self.target_ref.as_ref().map_or_else( + || "!".to_string(), + |t| { + format!( + "{target}{ahead}", + target = t.ref_name, + ahead = if t.commits_ahead == 0 { + "".to_string() + } else { + format!("⇣{}", t.commits_ahead) + } + ) + }, + ); + format!( + "{meta}{sign}:{name} <> ✓{target}{bound}", + meta = if self.metadata.is_some() { "📕" } else { "" }, + bound = self + .lower_bound + .map(|base| format!(" on {}", base.to_hex_with_len(7))) + .unwrap_or_default() + ) + } +} + +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.display_stacks().unwrap_or_default()) + .field("metadata", &self.metadata) + .field("target_ref", &self.target_ref) + .field("target_commit", &self.target_commit) + .finish() + } +} 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..614870cfc8f --- /dev/null +++ b/crates/but-graph/src/projection/api/queries.rs @@ -0,0 +1,565 @@ +//! 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::find_segment_owner_indexes_by_refname}; + +/// 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 + } + + /// 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 + .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.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 (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))) + }) + } + } + + /// 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. + 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)? + }) + } + + /// 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 { + self.segment_graph + .stacks + .iter() + .any(|stack| stack.segments.iter().find_map(|segment| segment.ref_name()) == Some(name)) + } + + /// 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 + } +} + +/// 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. +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)?; + // 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 top = stack.top()?; + top.tip().map(|id| (id, top)) + }) + .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.clone(), + commit_id: segment.tip(), + }); + 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..3dc6f960d1c --- /dev/null +++ b/crates/but-graph/src/projection/collect.rs @@ -0,0 +1,1680 @@ +//! Derive workspace stacks straight from the arena and its stored ref layout — +//! no segment is read. + +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; + +/// How the view anchors; only these three states are legal (a managed COMMIT implies a +/// managed ref). +#[derive(Clone, Copy, PartialEq, Eq)] +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. + ManagedRefOnly, + /// A plain checkout: no managed ref at all. + AdHoc, +} + +impl ViewAnchor { + 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 + } 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`: 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 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. +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)?; + // 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, + 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.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); + 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. + // (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) +} + +/// 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 +/// 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: &StackPartition, + key: gix::ObjectId, +) -> bool { + let mut positions = meta_stack.branches.iter().filter_map(|b| { + pos_by_name + .get(b.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: &[&StackPartition], + 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); + } + // 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 + // 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.claim_legs_of_anonymous_runs(); + } + (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: &[&StackPartition], + 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.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 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 + // 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). + /// 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)| { + (!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, + }); + } + } + + /// 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 + /// 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.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.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.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.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.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.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.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.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.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 StackPartition>, + 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() { + // 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) { + names.push(b); + } + } + 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]) + // 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 + } + 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()) { + 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)); + } + } + } + + /// 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) + }) + } + + /// 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: &[&'a gix::refs::FullName], + sip: &mut SegmentsInProgress<'a>, + displayed_commits: &HashSet, + ) { + 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); + } + } +} + +/// 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. +pub(super) 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: &[&StackPartition], + 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. +pub(super) struct LayoutIndexes<'a> { + pos_by_name: PosByName<'a>, + 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. + 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. +pub(crate) 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) +} + +pub(super) 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. +pub(super) 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..ca6ba3b321b --- /dev/null +++ b/crates/but-graph/src/projection/frame.rs @@ -0,0 +1,663 @@ +//! 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}; + +/// 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, Clone)] +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 { + /// 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; + 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..0410ef7975a --- /dev/null +++ b/crates/but-graph/src/projection/init.rs @@ -0,0 +1,1168 @@ +//! 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, + }, +}; + +/// 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 — 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 = "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 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 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.clone(), + segment_graph, + commit_graph: cg, + ctx, + tip_ref_info: f.tip_ref_info.clone(), + tip_commit_id: f.tip_commit, + 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, + }) +} + +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) + } +} + +/// 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 +/// 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, +) { + 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; + } + } + 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: 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, 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<_> = 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)); + } + /// 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, + }; + 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, + ); + } + } +} + +#[cfg(not(debug_assertions))] +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, + /// 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. + /// 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; + }; + 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(stacks, archived_ref_name) + else { + continue; + }; + let stack = &mut 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 = 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( + &self, + stacks: &mut Vec, + 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); + // 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 + .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(); + // 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, + &applied_branch_names, + ); + } + } + let orphan_chain_names = orphan_chain_names(metadata, stacks); + for stack in stacks.iter_mut() { + remove_empty_branches( + stack, + &applied_branch_names, + &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); + } + 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( + &self, + stacks: &mut [Stack], + 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<_> = 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 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)| { + 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(&self, stacks: &mut [Stack], cg: &crate::CommitGraph) { + let remote_named_at = remote_naming_positions(cg); + let naming = naming_positions(cg); + 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] + .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(&self, stacks: &mut [Stack]) { + if stacks.len() != 1 { + return; + } + let Some(stack) = 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, + 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 + // 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 + }; + // 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 +/// 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, + 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 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() + // 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 5804e49847f..e86a48a736e 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::reconcile_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,686 @@ 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, +} + +/// 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, + /// 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 the [`segment_graph`](Self::segment_graph) or + /// [`display_stacks`](Self::display_stacks). + pub metadata: Option, + + // ── 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, + /// 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 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.) +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, + 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, + 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, + upstream_advanced_past_target: false, + 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)?; + init::reconcile_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)?; + init::reconcile_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)?; + init::reconcile_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 +} + +/// 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)> { + 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)) + }) + }) +} + +/// The reconciled stack partition, reduced to what operations consume: per stack its +/// identity and ordered segments, each carrying the commit ids it claimed. Computed +/// eagerly at construction; extent questions are reads of [`commits`](Segment::commits), +/// not walks. +#[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/projection/stack.rs b/crates/but-graph/src/projection/stack.rs index 975aaef5fad..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,145 +232,26 @@ 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 { - self.debug_string_with_ref_name_remote(Graph::ref_and_remote_debug_string( + // Segment indices are ephemeral; keep them out of snapshot output. + 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(), - self.sibling_segment_id, - self.remote_tracking_branch_segment_id, + None, + None, )) } /// 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(), - self.sibling_segment_id, - self.remote_tracking_branch_segment_id, - ), - ) + 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 { @@ -446,10 +268,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 { @@ -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/mod.rs b/crates/but-graph/src/projection/workspace/api/mod.rs deleted file mode 100644 index 2c572e49cf4..00000000000 --- a/crates/but-graph/src/projection/workspace/api/mod.rs +++ /dev/null @@ -1,465 +0,0 @@ -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::{ - Stack, StackCommit, StackSegment, TargetRef, WorkspaceKind, - workspace::find_segment_owner_indexes_by_refname, - }, -}; - -/// A utility type to represent `(stack_idx, segment_idx, commit_idx)`. -pub type CommitOwnerIndexes = (usize, usize, CommitIndex); - -mod queries; -#[cfg(feature = "legacy")] -pub use queries::legacy::HeadStatus; - -/// Lifecycle -impl Workspace { - /// Redo the graph traversal with the same settings as before, but use the latest - /// data from `repo`, `meta` and `project_meta` to do it. - /// 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, - /// e.g. in the middle of an operation. - #[instrument( - name = "Workspace::refresh_from_head", - level = "debug", - skip_all, - err(Debug) - )] - pub fn refresh_from_head( - &mut self, - repo: &gix::Repository, - 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()?; - Ok(()) - } -} - -/// Query -impl Workspace { - /// Return `true` if the workspace has workspace metadata associated with it. - /// This is relevant when creating references for example. - pub fn has_metadata(&self) -> bool { - self.metadata.is_some() - } - - /// 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() - } - - /// Like [`Self::ref_name()`], but return a generic `` name for unnamed workspaces. - pub fn ref_name_display(&self) -> &BStr { - self.ref_name() - .map_or("".into(), |rn| rn.as_bstr()) - } -} - -/// Utilities -impl Workspace { - /// Return the name of the remote most closely associated with this workspace. - /// In order, we try: - /// - The remote name of the [Self::target_ref]. - /// - The remote name configured in [workspace metadata](Self::metadata). - /// - /// The caller *may* consider falling back to [`gix::Repository::remote_default_name()`], - /// but beware that one should handle ambiguity if there are more than one remotes. - pub fn remote_name(&self) -> Option { - if let Some(tr) = self.target_ref.as_ref() { - // TODO: should we rather get remote configuration from the repository? - let remote_names = self - .graph - .symbolic_remote_names - .iter() - .map(|name| name.as_str().into()) - .collect(); - 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() - } - } - - /// Return the resolved target commit ID for use as a base for new branches. - /// - /// 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. - /// - /// 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)) - }) - } - - /// 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()]. - /// 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, - /// whose commit is also returned as `target-commit-id`. - pub fn merge_base_with_target_branch( - &self, - 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), - ) - } - - /// Return `true` if the workspace itself is where `HEAD` is pointing to. - /// If `false`, one of the stack-segments is checked out instead. - pub fn is_entrypoint(&self) -> bool { - self.stacks - .iter() - .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 { - 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) - } - - /// 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) - } - - /// 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 - .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)) - }) - }) - }) - } - - /// 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")) - } - - /// 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) - } - - /// 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() - ) - }) - } - - /// Return `true` if `name` is contained in the workspace as segment. - pub fn refname_is_segment(&self, name: &gix::refs::FullNameRef) -> bool { - self.find_segment_and_stack_by_refname(name).is_some() - } - - /// Return `true` if `name` is in the ancestry of the workspace entrypoint, and is IN the workspace as well. - pub fn is_reachable_from_entrypoint(&self, name: &gix::refs::FullNameRef) -> bool { - if self.ref_name().filter(|_| self.is_entrypoint()) == Some(name) { - return true; - } - 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)) - }) - } - } - - /// Try to find `name` in any named [`StackSegment`] and return it along with the stack containing it. - pub fn find_segment_and_stack_by_refname( - &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)) - }) - }) - } - - /// Try to find a commit in the workspace and return it along with the segment and stack containing it. - pub fn find_commit_and_containers( - &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) - } - - /// 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)> { - self.find_segment_and_stack_by_refname(name) - .with_context(|| { - format!( - "Couldn't find any stack that contained the branch named '{}'", - name.shorten() - ) - }) - } -} - -/// Debugging -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 (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(), - ), - "🏘️", - ), - WorkspaceKind::ManagedMissingWorkspaceCommit { ref_info } => ( - graph.ref_debug_string_with_graph_context( - 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(), - ) - }), - "⌂", - ), - }; - let target = self.target_ref.as_ref().map_or_else( - || "!".to_string(), - |t| { - format!( - "{target}{ahead}", - target = t.ref_name, - ahead = if t.commits_ahead == 0 { - "".to_string() - } else { - format!("⇣{}", t.commits_ahead) - } - ) - }, - ); - format!( - "{meta}{sign}:{id}:{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))) - .unwrap_or_default() - ) - } -} - -/// 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) - .field("target_ref", &self.target_ref) - .field("target_commit", &self.target_commit) - .finish() - } -} 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/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 975f56ec53a..00000000000 --- a/crates/but-graph/src/projection/workspace/mod.rs +++ /dev/null @@ -1,318 +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(Debug, 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, -} - -/// 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)] -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, -} - -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/records.rs b/crates/but-graph/src/records.rs new file mode 100644 index 00000000000..4ae643be3bf --- /dev/null +++ b/crates/but-graph/src/records.rs @@ -0,0 +1,247 @@ +//! 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; + /// 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; + } +} + +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 { + // 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 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/ref_layout.rs b/crates/but-graph/src/ref_layout.rs new file mode 100644 index 00000000000..0ee8ecf3888 --- /dev/null +++ b/crates/but-graph/src/ref_layout.rs @@ -0,0 +1,445 @@ +//! 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. +/// +/// # 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) — 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. Never grows to cover edges added later. + 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, + /// 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 +/// 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) + }) + // 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 { + 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); + } +} 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/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..64f93493353 --- /dev/null +++ b/crates/but-graph/src/walk/overlay.rs @@ -0,0 +1,876 @@ +//! 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) + .map(|result| result.map(Cow::Owned)) + } + + 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 { + 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/init/remotes.rs b/crates/but-graph/src/walk/remotes.rs similarity index 90% rename from crates/but-graph/src/init/remotes.rs rename to crates/but-graph/src/walk/remotes.rs index 9ca1f4dd631..15990fb5bfd 100644 --- a/crates/but-graph/src/init/remotes.rs +++ b/crates/but-graph/src/walk/remotes.rs @@ -2,11 +2,12 @@ use std::collections::BTreeSet; use gix::reference::Category; -use crate::init::overlay::OverlayRepo; +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. -pub fn configured_remote_tracking_branches( +#[tracing::instrument(level = "trace", skip_all)] +pub(crate) fn configured_remote_tracking_branches( repo: &OverlayRepo<'_>, ) -> anyhow::Result> { let mut out = BTreeSet::default(); @@ -28,7 +29,7 @@ pub fn configured_remote_tracking_branches( // 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( +pub(crate) fn lookup_remote_tracking_branch_or_deduce_it( repo: &OverlayRepo<'_>, ref_name: &gix::refs::FullNameRef, symbolic_remote_names: &[String], @@ -60,7 +61,7 @@ pub fn lookup_remote_tracking_branch_or_deduce_it( })) } -pub fn lookup_remote_tracking_branch( +pub(crate) fn lookup_remote_tracking_branch( repo: &OverlayRepo<'_>, ref_name: &gix::refs::FullNameRef, ) -> anyhow::Result> { 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/init/walk/tests.rs b/crates/but-graph/src/walk/utils/tests.rs similarity index 100% rename from crates/but-graph/src/init/walk/tests.rs rename to crates/but-graph/src/walk/utils/tests.rs diff --git a/crates/but-graph/src/walk/walker.rs b/crates/but-graph/src/walk/walker.rs new file mode 100644 index 00000000000..18f2c9634e5 --- /dev/null +++ b/crates/but-graph/src/walk/walker.rs @@ -0,0 +1,1126 @@ +//! 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, + RemoteResolution { + symbolic_remote_names: &initial_seeds.symbolic_remote_names, + configured_tracking: &configured_remote_tracking_branches, + target_refs: &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 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 +/// 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 recorded — 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 recorded 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(); +} + +/// 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)] +fn try_queue_remote_tracking_branches( + repo: &OverlayRepo<'_>, + refs: &[gix::refs::FullName], + seed_segments: &mut Vec, + ep_seed: &mut Option, + next: &Queue, + remotes: RemoteResolution<'_>, + 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(), + remotes.symbolic_remote_names, + remotes.configured_tracking, + )? + else { + continue; + }; + if remotes.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, + }) +} 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/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/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 d20523bd315..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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓! on fafd9d0 -├── ≡:1:main <> origin/main →:2:⇡1⇣1 on fafd9d0 -│ └── :1:main <> origin/main →:2:⇡1⇣1 -│ ├── 🟣1f5c47b -│ ├── ·0a415d8 (🏘️) -│ └── ❄️73ba99d (🏘️) -└── ≡:3:A on fafd9d0 - └── :3: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#" -📕🏘️:0: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 3183e43 -├── ≡📙:3:A on 3183e43 {1} -│ └── 📙:3:A -│ └── ·7236012 (🏘️) -└── ≡📙:4:B on 3183e43 {2} - ├── 📙:4:B - │ └── ·68c8a9d (🏘️) - └── 📙:5: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 3183e43 -├── ≡📙:3:A on 3183e43 {1} -│ ├── 📙:3:A -│ │ └── ·7236012 (🏘️) -│ └── 📙:5:below -└── ≡📙:4:B on 3183e43 {2} - └── 📙:4: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 -└── ≡📙:5:B →:3: on 85efbe4 {1} - ├── 📙:5:B →:3: - │ ├── ·cc0bf57* - │ └── ·d69fe94 (🏘️) - └── 📙:4: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 3183e43 -├── ≡:7:anon: on a821094 {2} -│ └── :7:anon: -│ ├── ·835086d (🏘️) ►four, ►three -│ └── ·ff310d3 (🏘️) -└── ≡📙:3:X <> origin/X →:5:⇡1 on 3183e43 {1} - └── 📙:3:X <> origin/X →:5:⇡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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡:3:B <> origin/B →:4:⇡3 on fafd9d0 - └── :3:B <> origin/B →:4:⇡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#" -📕🏘️:1:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡:4:B <> origin/B →:5:⇡1 on fafd9d0 - ├── :4:B <> origin/B →:5:⇡1 - │ └── ·70e9a36 (🏘️) - └── 👉:0: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#" -📕🏘️:1:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡:4:B <> origin/B →:5:⇡1 on fafd9d0 - ├── :4:B <> origin/B →:5:⇡1 - │ └── ·70e9a36 (🏘️) - └── 👉:0: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#" -📕🏘️:1:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡:4:B <> origin/B →:5:⇡2 on fafd9d0 - ├── :4:B <> origin/B →:5:⇡2 - │ ├── ·70e9a36 (🏘️) - │ └── ·320e105 (🏘️) ►tags/without-ref - └── 👉:0: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#" -📕🏘️:1:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡:4:B <> origin/B →:5:⇡2 on fafd9d0 - ├── :4:B <> origin/B →:5:⇡2 - │ ├── ·70e9a36 (🏘️) - │ └── ·320e105 (🏘️) ►tags/without-ref - └── 👉:0: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡📙:4:B <> origin/B →:6:⇡2 on fafd9d0 {0} - ├── 📙:4:B <> origin/B →:6:⇡2 - │ ├── ·70e9a36 (🏘️) - │ └── ·320e105 (🏘️) ►tags/without-ref - ├── 📙:3:B-empty - │ └── ·2a31450 (🏘️) ►ambiguous-01 - ├── 📙:7:A-empty-03 - ├── 📙:8:A-empty-01 - └── 📙:9: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡📙:4:B <> origin/B →:6:⇡2 on fafd9d0 {0} - ├── 📙:4:B <> origin/B →:6:⇡2 - │ ├── ·70e9a36 (🏘️) - │ └── ·320e105 (🏘️) ►tags/without-ref - ├── 📙:3:B-empty - │ └── ·2a31450 (🏘️) ►ambiguous-01 - └── 📙:10: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#" -📕🏘️:1:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡📙:5:B <> origin/B →:6:⇡2 on fafd9d0 {1} - ├── 📙:5:B <> origin/B →:6:⇡2 - │ ├── ·70e9a36 (🏘️) - │ └── ·320e105 (🏘️) ►tags/without-ref - ├── 📙:4:B-empty - │ └── ·2a31450 (🏘️) ►ambiguous-01 - └── 👉📙:8: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#" -📕🏘️:1:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -├── ≡📙:5:B <> origin/B →:6:⇡2 on fafd9d0 {1} -│ ├── 📙:5:B <> origin/B →:6:⇡2 -│ │ ├── ·70e9a36 (🏘️) -│ │ └── ·320e105 (🏘️) ►tags/without-ref -│ ├── 📙:4:B-empty -│ │ └── ·2a31450 (🏘️) ►ambiguous-01 -│ └── 📙:10: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 - -"#]] - ); - 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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡:3:B on fafd9d0 - ├── :3:B - │ └── ·320e105 (🏘️) - ├── :4:B-sub - │ └── ·2a31450 (🏘️) - └── :5: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -├── ≡📙:3:B on fafd9d0 {0} -│ ├── 📙:3:B -│ │ └── ·320e105 (🏘️) -│ ├── 📙:4:B-sub -│ │ └── ·2a31450 (🏘️) -│ └── 📙:5:A -│ └── ·70bde6b (🏘️) -└── ≡📙:6:new-A on fafd9d0 {1} - └── 📙:6: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 0cc5a6f -└── ≡📙:3:C on 0cc5a6f {0} - ├── 📙:3:C - │ └── ·c6d714c (🏘️) - └── 📙:7: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 0cc5a6f -└── ≡📙:3:C {0} - └── 📙:3: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 0cc5a6f -├── ≡📙:3:C on 0cc5a6f {0} -│ └── 📙:3:C -│ └── ·c6d714c (🏘️) -└── ≡📙:7:merge on 0cc5a6f {1} - └── 📙:7: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 0cc5a6f -├── ≡📙:7:merge on 0cc5a6f {0} -│ └── 📙:7:merge -└── ≡📙:3:C on 0cc5a6f {1} - └── 📙:3: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#" -⌂:0:gitbutler/workspace[🌳] <> ✓! -└── ≡:0:gitbutler/workspace[🌳] {1} - └── :0: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#" -⌂:0:entrypoint <> ✓! -└── ≡:0:entrypoint {1} - └── :0: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#" -⌂:0:gitbutler/workspace[🌳] <> ✓! -└── ≡:0:gitbutler/workspace[🌳] {1} - ├── :0:gitbutler/workspace[🌳] - │ └── ·47e1cf1 - ├── :1:merge-2 - │ └── ·f40fb16 - ├── :2:D - │ ├── ·450c58a - │ └── ·0cc5a6f ►empty-1-on-merge, ►empty-2-on-merge, ►merge - ├── :5:B - │ └── ·7fdb58d - └── :7:main <> origin/main →:8: - └── ❄️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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡:6:merge-2 on fafd9d0 {0} - ├── :6:merge-2 - │ └── ·f40fb16 (🏘️) - ├── :7:D - │ └── ·450c58a (🏘️) - ├── 📙:9:empty-2-on-merge - ├── 📙:10:empty-1-on-merge - ├── 📙:11:merge - │ └── ·0cc5a6f (🏘️) - └── :4: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#" -📕🏘️:1:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡:5:merge-2 on fafd9d0 - ├── :5:merge-2 - │ └── ·f40fb16 (🏘️) - ├── 👉:0:C - │ ├── ·c6d714c (🏘️) - │ └── ·0cc5a6f (🏘️) ►empty-1-on-merge, ►empty-2-on-merge, ►merge - └── :6: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#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓! on fafd9d0 -├── ≡📙:2:A on fafd9d0 {1} -│ └── 📙:2:A -└── ≡📙:3:B on fafd9d0 {2} - └── 📙:3: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#" -📕🏘️⚠️:1:gitbutler/workspace[🌳] <> ✓! on fafd9d0 -├── ≡📙:2:A on fafd9d0 {1} -│ └── 📙:2:A -└── ≡👉📙:3:B on fafd9d0 {2} - └── 👉📙:3: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#" -📕🏘️⚠️:1:gitbutler/workspace[🌳] <> ✓! on fafd9d0 -├── ≡👉📙:2:A on fafd9d0 {1} -│ └── 👉📙:2:A -└── ≡📙:3:B on fafd9d0 {2} - └── 📙:3: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#" -⌂:0:main[🌳] <> ✓refs/remotes/origin/main -└── ≡:0:main[🌳] <> origin/main →:2: {1} - └── :0:main[🌳] <> origin/main →:2: - -"#]] - ); - - 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#" -📕🏘️⚠️:0:gitbutler/workspace <> ✓! -└── ≡:1:main[🌳] <> origin/main →:2: - └── :1:main[🌳] <> origin/main →:2: - └── ❄️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#" -📕🏘️⚠️: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 - -"#]] - ); - - 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#" -📕🏘️⚠️: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 - -"#]] - ); - - ws.graph.anonymize(&repo.remote_names())?; - 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 - -"#]] - ); - - 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#" -📕🏘️⚠️:0: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#" -📕🏘️⚠️: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 - -"#]] - ); - 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#" -📕🏘️⚠️: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 - -"#]] - ); - 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#" -📕🏘️⚠️:0:gitbutler/workspace <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡📙:3:C on fafd9d0 {0} - ├── 📙:3:C - ├── 📙:4:B - └── 📙:5: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#" -📕🏘️⚠️:0:gitbutler/workspace <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡📙:3:C {0} - └── 📙:3: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#" -📕🏘️⚠️:0:gitbutler/workspace <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡📙:3:C {0} - ├── 📙:3:C - └── 📙:4: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#" -📕🏘️⚠️:0: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡:3:anon: on fafd9d0 - └── :3: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#" -📕🏘️:1:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡👉:0:S1 on fafd9d0 - └── 👉:0: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#" -📕🏘️: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 - │ └── ·16f132b (🏘️) - └── 📙:12: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#" -📕🏘️: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 - │ └── ·16f132b (🏘️) - └── 📙:12: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#" -📕🏘️⚠️: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 - -"#]] - ); - - 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#" -📕🏘️⚠️: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 - -"#]] - ); - 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#" -📕🏘️:0: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#" -⌂:0:main <> ✓refs/remotes/origin/main⇣2 -└── ≡:0:main <> origin/main →:2:⇣2 {1} - └── :0:main <> origin/main →:2:⇣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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓! -└── ≡:1:A <> origin/A →:2:⇡2⇣4 - ├── :1:A <> origin/A →:2:⇡2⇣4 - │ ├── 🟣3ea1a8f - │ ├── 🟣9c50f71 - │ ├── 🟣2cfbb79 - │ ├── 🟣e898cd0 - │ ├── ·9d34471 (🏘️) - │ ├── ·5b89c71 (🏘️) - │ └── ❄️998eae6 (🏘️) - └── :3: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#" -📕🏘️:1:gitbutler/workspace[🌳] <> ✓! -└── ≡:2:A <> origin/A →:3:⇡2⇣4 - ├── :2:A <> origin/A →:3:⇡2⇣4 - │ ├── 🟣3ea1a8f - │ ├── 🟣9c50f71 - │ ├── 🟣2cfbb79 - │ ├── 🟣e898cd0 - │ ├── ·9d34471 (🏘️) - │ ├── ·5b89c71 (🏘️) - │ └── ❄️998eae6 (🏘️) - └── 👉:0: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓! -└── ≡:1:A <> push-remote/A →:2:⇡2⇣4 - ├── :1:A <> push-remote/A →:2:⇡2⇣4 - │ ├── 🟣3ea1a8f - │ ├── 🟣9c50f71 - │ ├── 🟣2cfbb79 - │ ├── 🟣e898cd0 - │ ├── ·9d34471 (🏘️) - │ ├── ·5b89c71 (🏘️) - │ └── ❄️998eae6 (🏘️) - └── :3: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓! -└── ≡:1:B - ├── :1:B - │ └── ·312f819 (🏘️) - ├── :2:A - │ └── ·e255adc (🏘️) - └── :3:main <> origin/main →:4: - └── ❄️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#" -📕🏘️: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 - │ ├── 🟣682be32 - │ └── ·312f819 (🏘️) - └── 👉:0:A <> origin/A →:4:⇡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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡📙:2:A on fafd9d0 {1} - └── 📙:2: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡📙:3:A on fafd9d0 {1} - ├── 📙:3:A - └── 📙:4:main <> origin/main →:1:⇡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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡📙:3:main <> origin/main →:1: on fafd9d0 {1} - ├── 📙:3:main <> origin/main →:1: - └── 📙:4: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡:6:anon: on fafd9d0 - ├── :6:anon: - │ └── ·2173153 (🏘️) ►C, ►ambiguous-C - ├── :9:B <> origin/B →:5:⇣1 - │ ├── 🟣ac24e74 - │ └── ❄️312f819 (🏘️) ►ambiguous-B - └── :8:A <> origin/A →:7: - └── ❄️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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡📙:3:C <> origin/C →:4: on fafd9d0 {0} - ├── 📙:3:C <> origin/C →:4: - │ └── ❄️2173153 (🏘️) ►ambiguous-C - ├── :9:B <> origin/B →:6:⇣1 - │ ├── 🟣ac24e74 - │ └── ❄️312f819 (🏘️) ►ambiguous-B - └── :8:A <> origin/A →:7: - └── ❄️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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓! -└── ≡:1:B - ├── :1:B - │ ├── ·6b1a13b (🏘️) - │ └── ·03ad472 (🏘️) - ├── :2:A - │ ├── ·79bbb29 (🏘️) - │ ├── ·fc98174 (🏘️) - │ ├── ·a381df5 (🏘️) - │ ├── ·777b552 (🏘️) - │ ├── ·ce4a760 (🏘️) - │ └── ·01d0e1e (🏘️) - └── :6: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣6 on 79bbb29 -└── ≡📙:2:B on 79bbb29 {0} - └── 📙:2: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣6 on 79bbb29 -└── ≡📙:2:B on 79bbb29 {0} - └── 📙:2: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#" -⌂:0:A <> ✓refs/remotes/origin/main⇣3 -└── ≡:0:A on 4b3e5a8 {1} - └── :0: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#" -⌂:0:A <> ✓refs/remotes/origin/main⇣6 -└── ≡:0:A {1} - └── :0: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#" -⌂:0:DETACHED <> ✓refs/remotes/origin/main⇣3 on 79bbb29 -└── ≡:0:anon: on 4b3e5a8 {1} - └── :0: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#" -⌂:0:DETACHED <> ✓refs/remotes/origin/main⇣3 on 79bbb29 -└── ≡:0:anon: on 4b3e5a8 {1} - └── :0: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣3 on 79bbb29 -└── ≡:4:B on 79bbb29 - └── :4: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣3 on 4b3e5a8 -└── ≡:4:B on 4b3e5a8 - ├── :4:B - │ ├── ·6b1a13b (🏘️) - │ └── ·03ad472 (🏘️) - └── :5: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#" -⌂:0:A <> ✓refs/remotes/origin/main⇣3 -└── ≡:0:A on 4b3e5a8 {1} - └── :0: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#" -📕🏘️:1:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣3 on 4b3e5a8 -└── ≡:5:B on 4b3e5a8 - ├── :5:B - │ ├── ·6b1a13b (🏘️) - │ └── ·03ad472 (🏘️) - └── 👉:0: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#" -⌂:0:main <> ✓refs/remotes/origin/main⇣3 -└── ≡:0:main <> origin/main →:2:⇣3 {1} - └── :0:main <> origin/main →:2:⇣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#" -⌂:0:DETACHED <> ✓refs/remotes/origin/main⇣3 -└── ≡:0:anon: {1} - └── :0: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#" -📕🏘️⚠️:1:gitbutler/workspace <> ✓! -└── ≡👉📙:0:main[🌳] <> origin/main →:2:⇡1 {0} - └── 👉📙:0:main[🌳] <> origin/main →:2:⇡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#" -📕🏘️⚠️:1:gitbutler/workspace <> ✓! -└── ≡👉📙:0:main[🌳] <> origin/main →:2:⇡1 {0} - └── 👉📙:0:main[🌳] <> origin/main →:2:⇡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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓! - -"#]] - ); - - meta.data_mut().branches.clear(); - add_workspace(&mut meta); - assert!( - meta.data_mut().default_target.is_some(), - "But with workspace and target, we see everything" - ); - // It's notable that there is no way to bypass the early abort when everything is integrated. - // 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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣6 on 79bbb29 -└── ≡:3:B on 79bbb29 - └── :3: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#" -📕🏘️: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: - └── ❄️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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡📙:5:dependent on fafd9d0 {1} - ├── 📙:5:dependent - └── 📙:6:advanced-lane <> origin/advanced-lane →:4: - └── ❄️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#" -📕🏘️⚠️:0: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#" -📕🏘️⚠️: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 - -"#]] - ); - - // 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#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 2be54cd -├── ≡📙:3:C on 2be54cd {0} -│ ├── 📙:3:C -│ ├── 📙:4:B -│ └── 📙:5:A -│ ├── ·2cde30a (🏘️|✓) -│ ├── ·1c938f4 (🏘️|✓) -│ ├── ·b82769f (🏘️|✓) -│ ├── ·988032f (🏘️|✓) -│ └── ·cd5b655 (🏘️|✓) -└── ≡📙:6:D on 2be54cd {1} - ├── 📙:6:D - ├── 📙:7:E - └── 📙:8: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#" -⌂:0:main <> ✓refs/remotes/origin/main⇣11 -└── ≡:0:main <> origin/main →:2:⇣11 {1} - └── :0:main <> origin/main →:2:⇣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#" -⌂:0:main <> ✓refs/remotes/origin/main⇣11 -└── ≡:0:main <> origin/main →:2:⇣11 {1} - └── :0:main <> origin/main →:2:⇣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#" -📕🏘️:0: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#" -📕🏘️: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 - ├── 🟣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#" -⌂:0:main <> ✓refs/remotes/origin/main⇣17 -└── ≡:0:main <> origin/main →:2:⇣17 {1} - └── :0:main <> origin/main →:2:⇣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#" -📕🏘️:0: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#" -📕🏘️:0: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#" -📕🏘️:0: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#" -📕🏘️:0: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣1 on 3183e43 -├── ≡:3:D on 3183e43 -│ ├── :3:D -│ │ └── ·9895054 (🏘️) -│ ├── :6:C -│ │ ├── ·de625cc (🏘️) -│ │ ├── ·23419f8 (🏘️) -│ │ └── ·5dc4389 (🏘️) -│ └── :7:shared -│ ├── ·d4f537e (🏘️|✓) -│ ├── ·b448757 (🏘️|✓) -│ └── ·e9a378d (🏘️|✓) -├── ≡:4:A on 3183e43 -│ ├── :4:A -│ │ └── ·0bad3af (🏘️|✓) -│ └── :7:shared -│ ├── ·d4f537e (🏘️|✓) -│ ├── ·b448757 (🏘️|✓) -│ └── ·e9a378d (🏘️|✓) -└── ≡:5:B on 3183e43 - ├── :5:B - │ ├── ·acdc49a (🏘️) - │ └── ·f0117e0 (🏘️) - └── :7: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣1 on d4f537e -├── ≡:3:D on d4f537e -│ ├── :3:D -│ │ └── ·9895054 (🏘️) -│ └── :6:C -│ ├── ·de625cc (🏘️) -│ ├── ·23419f8 (🏘️) -│ └── ·5dc4389 (🏘️) -└── ≡:5:B on d4f537e - └── :5: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣1 on 3183e43 -├── ≡:2:D on 3183e43 -│ ├── :2:D -│ │ └── ·9895054 (🏘️) -│ ├── :6:C -│ │ ├── ·de625cc (🏘️) -│ │ ├── ·23419f8 (🏘️) -│ │ └── ·5dc4389 (🏘️) -│ └── :7:shared -│ ├── ·d4f537e (🏘️) -│ ├── ·b448757 (🏘️) -│ └── ·e9a378d (🏘️) -├── ≡:3:A on 3183e43 -│ ├── :3:A -│ │ └── ·0bad3af (🏘️) -│ └── :7:shared -│ ├── ·d4f537e (🏘️) -│ ├── ·b448757 (🏘️) -│ └── ·e9a378d (🏘️) -└── ≡:4:B on 3183e43 - ├── :4:B - │ ├── ·acdc49a (🏘️) - │ └── ·f0117e0 (🏘️) - └── :7: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#" -📕🏘️:1:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣1 on 3183e43 -├── ≡:4:D on 3183e43 -│ ├── :4:D -│ │ └── ·9895054 (🏘️) -│ ├── :7:C -│ │ ├── ·de625cc (🏘️) -│ │ ├── ·23419f8 (🏘️) -│ │ └── ·5dc4389 (🏘️) -│ └── :3:shared -│ ├── ·d4f537e (🏘️) -│ ├── ·b448757 (🏘️) -│ └── ·e9a378d (🏘️) -├── ≡👉:0:A on 3183e43 -│ ├── 👉:0:A -│ │ └── ·0bad3af (🏘️) -│ └── :3:shared -│ ├── ·d4f537e (🏘️) -│ ├── ·b448757 (🏘️) -│ └── ·e9a378d (🏘️) -└── ≡:5:B on 3183e43 - ├── :5:B - │ ├── ·acdc49a (🏘️) - │ └── ·f0117e0 (🏘️) - └── :3: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡📙:5:dependent on fafd9d0 {1} - ├── 📙:5:dependent - └── 📙:6:advanced-lane <> origin/advanced-lane →:4: - └── ❄️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#" -📕🏘️: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 - └── ❄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#" -📕🏘️: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 - └── ❄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#" -📕🏘️: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 - └── ❄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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -├── ≡📙:3:C-on-A on fafd9d0 {1} -│ ├── 📙:3:C-on-A -│ │ └── ·4f1bb32 (🏘️) -│ └── :4:A <> origin/A →:5:⇣1 -│ ├── 🟣b627ca7 -│ └── ❄️e255adc (🏘️) -└── ≡:6:B-on-A on fafd9d0 - ├── :6:B-on-A - │ └── ·aff8449 (🏘️) - └── :4:A <> origin/A →:5:⇣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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣1 on fafd9d0 -├── ≡📙:2:advanced-lane on fafd9d0 {0} -│ └── 📙:2:advanced-lane -│ └── ·cbc6713 (🏘️) -└── ≡📙:4:lane on fafd9d0 {1} - └── 📙:4: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#" -📕🏘️: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 - └── ·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#" -📕🏘️: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 - │ ├── 🟣2b1808c - │ ├── ·aadad9d (🏘️) - │ └── ·96a2408 (🏘️|✓) - └── :5: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#" -📕🏘️: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 - ├── 🟣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#" -📕🏘️: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 - │ ├── 🟣e0bd0a7 - │ └── ·da597e8 (🏘️) - └── 📙:4:A <> origin/A →:6:⇣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#" -📕🏘️: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 - ├── 🟣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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡📙:3:top on fafd9d0 {0} - ├── 📙:3:top - │ └── ❄bfbff44 (🏘️) - └── 📙:4:bottom <> origin/bottom →:5:⇣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#" -📕🏘️: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 - ├── 🟣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#" -📕🏘️: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 - ├── 🟣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#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓! -└── ≡:1:A <> origin/A →:2:⇣1 - ├── :1:A <> origin/A →:2:⇣1 - │ ├── 🟣4fe5a6f - │ ├── ❄️a62b0de (🏘️) - │ └── ❄️120a217 (🏘️) - └── :3: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#" -📕🏘️⚠️:1:gitbutler/workspace[🌳] <> ✓! -└── ≡👉:0:A <> origin/A →:2:⇣1 - ├── 👉:0:A <> origin/A →:2:⇣1 - │ ├── 🟣4fe5a6f - │ ├── ❄️a62b0de (🏘️) - │ └── ❄️120a217 (🏘️) - └── :3: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#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓! -└── ≡:1:A <> origin/A →:2:⇣1 - ├── :1:A <> origin/A →:2:⇣1 - │ ├── 🟣4fe5a6f - │ ├── ❄️a62b0de (🏘️) ►B - │ └── ❄️120a217 (🏘️) - └── :3: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#" -📕🏘️⚠️:1:gitbutler/workspace[🌳] <> ✓! -└── ≡👉:4:A <> origin/A →:2:⇣1 {1} - ├── 👉:4:A <> origin/A →:2:⇣1 - │ └── 🟣4fe5a6f - ├── 📙:0:B - │ ├── ❄a62b0de (🏘️) - │ └── ❄120a217 (🏘️) - └── :3: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#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓! -└── ≡📙:1:B {1} - ├── 📙:1:B - │ ├── ·a62b0de (🏘️) ►A - │ └── ·120a217 (🏘️) - └── :3: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#" -📕🏘️⚠️:1:gitbutler/workspace[🌳] <> ✓! -└── ≡📙:4:B {1} - ├── 📙:4:B - ├── 👉📙:5:A <> origin/A →:2:⇣1 - │ ├── 🟣4fe5a6f - │ ├── ❄️a62b0de (🏘️) - │ └── ❄️120a217 (🏘️) - └── :3: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#" -📕🏘️⚠️:1:gitbutler/workspace[🌳] <> ✓! -└── ≡👉📙:4:A <> origin/A →:2:⇣1 {1} - ├── 👉📙:4:A <> origin/A →:2:⇣1 - │ └── 🟣4fe5a6f - ├── 📙:5:B - │ ├── ❄a62b0de (🏘️) - │ └── ❄120a217 (🏘️) - └── :3: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#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓! -└── ≡:1:anon: - ├── :1:anon: - │ ├── ·a62b0de (🏘️) ►A, ►B - │ └── ·120a217 (🏘️) - └── :4: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#" -📕🏘️⚠️:1:gitbutler/workspace[🌳] <> ✓! -└── ≡👉:0:A <> origin/A →:2: - ├── 👉:0:A <> origin/A →:2: - │ ├── ❄️a62b0de (🏘️) ►B - │ └── ❄️120a217 (🏘️) - └── :4: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#" -📕🏘️⚠️:1:gitbutler/workspace[🌳] <> ✓! -└── ≡👉:0:B <> origin/B →:3: - ├── 👉:0:B <> origin/B →:3: - │ ├── ❄️a62b0de (🏘️) ►A - │ └── ❄️120a217 (🏘️) - └── :4: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#" -📕🏘️⚠️:1:gitbutler/workspace[🌳] <> ✓! -└── ≡👉:5:A <> origin/A →:2: {1} - ├── 👉:5:A <> origin/A →:2: - ├── 📙:0:B <> origin/B →:3: - │ ├── ❄️a62b0de (🏘️) - │ └── ❄️120a217 (🏘️) - └── :4: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓! -└── ≡:1:A <> origin/A →:2:⇣1 - ├── :1:A <> origin/A →:2:⇣1 - │ ├── 🟣4fe5a6f - │ ├── ❄️a62b0de (🏘️) - │ └── ❄️120a217 (🏘️) - └── :3: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#" -📕🏘️:1:gitbutler/workspace[🌳] <> ✓! -└── ≡👉:0:A <> origin/A →:2:⇣1 - ├── 👉:0:A <> origin/A →:2:⇣1 - │ ├── 🟣4fe5a6f - │ ├── ❄️a62b0de (🏘️) - │ └── ❄️120a217 (🏘️) - └── :3: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#" -📕🏘️:0: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#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓! -└── ≡:1:anon: - ├── :1:anon: - │ └── ·dca94a4 (🏘️) - ├── :2:A - │ └── ·120a217 (🏘️) - └── :3: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#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -├── ≡📙:3:lane on fafd9d0 {0} -│ ├── 📙:3: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 - -"#]] - ); - - // 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#" -📕🏘️⚠️: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 - │ └── ·cbc6713 (🏘️) - ├── 📙:7:lane-segment-01 - └── 📙:8: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣1 on 281456a -└── ≡:3:B on 281456a - ├── :3:B - │ └── ·da597e8 (🏘️) - └── :4:A <> origin/A →:5:⇡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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓! -└── ≡:1:main - └── :1: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/gitbutler/target on ce09734 -└── ≡:3:A on ce09734 - ├── :3:A - │ ├── ·c59457b (🏘️) - │ └── ·e146f13 (🏘️) - └── :5:main <> origin/main →:6: - └── ❄️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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣2 on fafd9d0 -├── ≡:6:B on fafd9d0 -│ └── :6:B -│ └── ·2f8f06d (🏘️) -├── ≡:7:C on fafd9d0 -│ └── :7:C -│ ├── ·3f7c4e6 (🏘️) -│ └── ·b6895d7 (🏘️) -└── ≡:8:new-name-for-D on fafd9d0 - └── :8: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣2 on fafd9d0 -├── ≡📙:19:A →:4: on fafd9d0 {0} -│ ├── 📙:19:A →:4: -│ │ ├── ·c83f258* -│ │ └── ·a62b0de (🏘️|✓) -│ └── 📙:21:A-middle <> origin/A-middle →:5: -│ ├── ·27c2545* -│ └── ·120a217 (🏘️|✓) -├── ≡📙:6:B on fafd9d0 {1} -│ ├── 📙:6:B -│ │ └── ·2f8f06d (🏘️) -│ └── 📙:15:B-middle <> origin/B-middle →:7: -│ ├── ·c8f73c7* -│ ├── ·ff75b80* -│ ├── ·91bc3fc (🏘️|✓) -│ └── ·cf9330f (🏘️|✓) -├── ≡📙:8:C on fafd9d0 {2} -│ ├── 📙:8:C -│ │ └── ·3f7c4e6 (🏘️) -│ └── 📙:20:C-bottom →:9: -│ ├── ·790a17d* -│ ├── ·969aaec* -│ ├── ·631be19* -│ └── ·b6895d7 (🏘️) -└── ≡:18:new-name-for-D on fafd9d0 - └── :18: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#" -📕🏘️: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 - └── ·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#" -📕🏘️: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 - └── ·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#" -📕🏘️: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 - └── ·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#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on bce0c5e -└── ≡:5:anon: on bce0c5e {1} - ├── :5:anon: - │ └── ·a7131b1 (🏘️) - ├── :6:intermediate-ref - │ ├── ·4d3831e (🏘️) - │ ├── ·468357f (🏘️) - │ ├── ·118ddbb (🏘️) - │ └── ·619d548 (🏘️) - └── 📙:4: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#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on bce0c5e -└── ≡:5:anon: on bce0c5e {1} - ├── :5:anon: - │ └── ·a7131b1 (🏘️) - ├── :6:intermediate-ref - │ ├── ·4d3831e (🏘️) - │ ├── ·468357f (🏘️) - │ ├── ·118ddbb (🏘️) - │ └── ·619d548 (🏘️) - └── 📙:4: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#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on bce0c5e -└── ≡:4:anon: on bce0c5e {0} - ├── :4:anon: - │ └── ·da912a8 (🏘️) - ├── :5:intermediate-ref - │ ├── ·198eaf8 (🏘️) - │ ├── ·3147997 (🏘️) - │ ├── ·9785229 (🏘️) - │ └── ·c58f157 (🏘️) - └── 📙:3: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on b625665 -└── ≡📙:3:A <> origin/A →:4: on b625665 {1} - └── 📙:3:A <> origin/A →:4: - └── ❄️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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓! -└── ≡📙:1:A {1} - └── 📙: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓! on bce0c5e -├── ≡:1:B on bce0c5e -│ └── :1:B -│ ├── ·78b1b59 (🏘️) -│ └── ·f52fcec (🏘️) -└── ≡:2:A on bce0c5e - └── :2: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣1 on bce0c5e -├── ≡📙:3:A on bce0c5e {0} -│ └── 📙:3:A -│ └── ·6fdab32 (🏘️) -└── ≡📙:4:B on bce0c5e {1} - └── 📙:4: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣1 on bce0c5e -├── ≡📙:4:A on bce0c5e {0} -│ └── 📙:4:A -│ └── ·6fdab32 (🏘️) -└── ≡📙:5:B on f52fcec {1} - └── 📙:5: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓! on bce0c5e -├── ≡:1:B on bce0c5e -│ └── :1:B -│ └── ·ce25240 (🏘️) -└── ≡:2:A on bce0c5e - ├── :2:A - │ └── ·de6d39c (🏘️) - └── :3:main <> origin/main →:4: - └── ❄️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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 3183e43 -├── ≡📙:3:A on 3183e43 {1} -│ ├── 📙:3: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 - │ └── ·f9e2cb7 (🏘️) - ├── 📙:13:C1-3 - ├── 📙:14:C1-2 - ├── 📙:15:C1-1 - │ └── ·aaa195b (🏘️) - ├── 📙:16:below-C - └── 📙:17: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#" -📕🏘️: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 -│ │ └── ·f9e2cb7 (🏘️) -│ ├── 📙:13:C1-3 -│ ├── 📙:14:C1-2 -│ ├── 📙:15:C1-1 -│ │ └── ·aaa195b (🏘️) -│ ├── 📙:16:below-C -│ └── 📙:17:below-below-C -└── ≡📙:5:A on 3183e43 {1} - └── 📙:5: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#" -📕🏘️: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 - └── 🟣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#" -📕🏘️: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 - └── 🟣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#" -📕🏘️: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 - ├── 🟣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#" -📕🏘️:0: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡📙:3:unapplied on fafd9d0 {1} - └── 📙:3: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#" -📕🏘️:0: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on ce09734 -└── ≡📙:3:survivor on ce09734 {1} - └── 📙:3: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on ce09734 -├── ≡📙:3:survivor on ce09734 {1} -│ └── 📙:3:survivor -│ ├── ·4ca0966 (🏘️) -│ └── ·a3b180e (🏘️) -└── ≡📙:4:unapplied on ce09734 {2} - └── 📙:4: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓! -└── ≡:2:main <> origin/main →:1: - └── :2:main <> origin/main →:1: - └── ❄️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#" -📕🏘️: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: - -"#]] - ); - - // 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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡📙:3:main <> origin/main →:1: on fafd9d0 {2} - └── 📙:3:main <> origin/main →:1: - -"#]] - ); - - 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#" -📕🏘️⚠️: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 - -"#]] - ); - 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#" -📕🏘️: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 -│ └── 🟣197ddce -└── ≡:5:B[📁wt-B-inside] on 081bae9 - └── :5: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#" -📕🏘️: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 -│ └── 🟣197ddce -└── ≡👉:0:B[📁wt-B-inside@repo] on 081bae9 - └── 👉:0: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡📙:3:A on fafd9d0 {1} - └── 📙:3: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -├── ≡📙:3:A on fafd9d0 {1} -│ └── 📙:3:A -└── ≡📙:4:B on fafd9d0 {2} - └── 📙:4: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡📙:3:A on fafd9d0 {1} - ├── 📙:3:A - └── 📙:4: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣1 on fafd9d0 -└── ≡📙:3:A on fafd9d0 {1} - └── 📙:3: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣1 on fafd9d0 -├── ≡📙:3:A on fafd9d0 {1} -│ └── 📙:3:A -└── ≡📙:4:B on fafd9d0 {2} - └── 📙:4: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣1 on fafd9d0 -└── ≡📙:3:A on fafd9d0 {1} - ├── 📙:3:A - └── 📙:4: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣1 on fafd9d0 -├── ≡📙:3:A on fafd9d0 {1} -│ └── 📙:3:A -└── ≡📙:4:B on fafd9d0 {2} - └── 📙:4: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣1 on fafd9d0 -└── ≡📙:3:A on fafd9d0 {1} - ├── 📙:3:A - └── 📙:4: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡:3:A on fafd9d0 - └── :3: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#" -📕🏘️:1:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡:4:A on fafd9d0 - ├── :4:A - │ └── ·a62b0de (🏘️) - └── 👉:0: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣10 on 68e62aa -└── ≡:12:anon: on 68e62aa - └── :12: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#" -📕🏘️: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 - ├── ·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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on e32cf47 -├── ≡📙:5:A on e32cf47 {0} -│ └── 📙:5:A -│ └── ·81d4e38 (🏘️) -└── ≡📙:4:B on e32cf47 {1} - └── 📙:4: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣1 on f5f42e0 -└── ≡📙:3:local-stack {0} - └── 📙:3: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on ef56fab -└── ≡📙:3:my-branch {0} - └── 📙:3: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣1 on fafd9d0 -└── ≡📙:4:my-branch on fafd9d0 {0} - └── 📙:4: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣1 on 312f819 -└── ≡📙:5:my-branch on 312f819 {0} - └── 📙:5: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣1 on 322cb14 -├── ≡📙:4:my-branch on 2121f9c {0} -│ └── 📙:4:my-branch -│ └── ·f5055a1 (🏘️) -└── ≡📙:5:old-branch on 322cb14 {1} - └── 📙:5: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on d263f88 -└── ≡📙:4:X on b4bd43f {0} - └── 📙:4: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡:3:A on fafd9d0 - └── :3: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#" -📕🏘️:1:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡:4:A on fafd9d0 - └── :4: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡:3:anon: on fafd9d0 - └── :3: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#" -📕🏘️:0: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on fafd9d0 -└── ≡:3:anon: on fafd9d0 - └── :3:anon: - ├── ·a62b0de (🏘️) - └── ·120a217 (🏘️) - -"#]] - ); - Ok(()) -} 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/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/init/mod.rs b/crates/but-graph/tests/graph/walk/mod.rs similarity index 53% rename from crates/but-graph/tests/graph/init/mod.rs rename to crates/but-graph/tests/graph/walk/mod.rs index 97fe3bf4b27..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,83 +13,24 @@ 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#" -⌂:0:main[🌳] <> ✓! -└── ≡:0:main[🌳] {1} - └── :0:main[🌳] +⌂:main[🌳] <> ✓! +└── ≡:main[🌳] {1} + └── :main[🌳] "#]] ); @@ -111,148 +52,47 @@ 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#" -⌂:0:DETACHED <> ✓! -└── ≡:0:anon: {1} - ├── :0:anon: +⌂:DETACHED <> ✓! +└── ≡:anon: {1} + ├── :anon: │ └── ·541396b ►tags/annotated, ►tags/release/v1, ►main - └── :1:other + └── :other └── ·fafd9d0 "#]] @@ -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,27 +148,21 @@ 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#" -⌂: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 "#]] ); @@ -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,37 +201,31 @@ 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#" -⌂: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 "#]] @@ -431,34 +248,29 @@ 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#" -⌂: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 @@ -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,11 +316,11 @@ 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#" -⌂: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 "#]] @@ -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,11 +366,11 @@ 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#" -⌂: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 "#]] @@ -599,49 +400,43 @@ 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#" -⌂:0:main[🌳] <> ✓! -└── ≡:0:main[🌳] {1} - └── :0:main[🌳] +⌂:main[🌳] <> ✓! +└── ≡:main[🌳] {1} + └── :main[🌳] ├── ·c6c8c05 ├── ·76fc5c4 └── ·e5d0542 @@ -676,62 +471,50 @@ 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#" -⌂:0:merged[🌳] <> ✓! -└── ≡:0:merged[🌳] {1} - ├── :0:merged[🌳] +⌂:merged[🌳] <> ✓! +└── ≡:merged[🌳] {1} + ├── :merged[🌳] │ └── ·8a6c109 - ├── :1:A + ├── :A │ ├── ·62b409a │ └── ·592abec - └── :7:main + └── :main └── ·965998b "#]] @@ -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(()) +} + +#[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(); -└── ►:0[0]:tags/release/v1 - └── 👉►:1[1]:main - └── ·541396b (⌂|1) ►tags/annotated, ►tags/release/v1 - └── ►:2[2]:other - └── 🏁·fafd9d0 (⌂|1) + // 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,48 +894,38 @@ 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#" -⌂: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 (✓) @@ -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,34 +971,27 @@ 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#" -⌂: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 @@ -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#" -⌂:0:B[🌳] <> ✓refs/remotes/origin/B⇣1 on 312f819 -└── ≡:0:B[🌳] <> origin/B →:1:⇣1 on e255adc {1} - └── :0:B[🌳] <> origin/B →:1:⇣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,25 +1068,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]: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#" -⌂: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 @@ -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,46 +1128,40 @@ 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#" -⌂:0:C[🌳] <> ✓! -└── ≡:0:C[🌳] {1} - ├── :0:C[🌳] +⌂:C[🌳] <> ✓! +└── ≡:C[🌳] {1} + ├── :C[🌳] │ ├── ·2a95729 │ ├── ·6861158 │ ├── ·4f1f248 │ └── ·487ffce - └── :4:main + └── :main ├── ·edc4dee ├── ·01d0e1e ├── ·4b3e5a8 @@ -1353,36 +1173,28 @@ 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#" -⌂:0:C[🌳] <> ✓! -└── ≡:0:C[🌳] {1} - └── :0:C[🌳] +⌂:C[🌳] <> ✓! +└── ≡:C[🌳] {1} + └── :C[🌳] └── ✂️·2a95729 "#]] ); // 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,26 +1202,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) - ├── ►: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#" -⌂:0:C[🌳] <> ✓! -└── ≡:0:C[🌳] {1} - └── :0:C[🌳] +⌂:C[🌳] <> ✓! +└── ≡:C[🌳] {1} + └── :C[🌳] ├── ·2a95729 └── ✂️·6861158 @@ -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,29 +1256,24 @@ 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#" -⌂:0:C[🌳] <> ✓! -└── ≡:0:C[🌳] {1} - └── :0:C[🌳] +⌂:C[🌳] <> ✓! +└── ≡:C[🌳] {1} + └── :C[🌳] ├── ·2a95729 ├── ·6861158 └── ✂️·4f1f248 @@ -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,30 +1293,25 @@ 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#" -⌂:0:C[🌳] <> ✓! -└── ≡:0:C[🌳] {1} - └── :0:C[🌳] +⌂:C[🌳] <> ✓! +└── ≡:C[🌳] {1} + └── :C[🌳] ├── ·2a95729 ├── ·6861158 └── ✂️·4f1f248 @@ -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,87 +1331,53 @@ 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#" -⌂:0:C[🌳] <> ✓! -└── ≡:0:C[🌳] {1} - └── :0:C[🌳] +⌂:C[🌳] <> ✓! +└── ≡:C[🌳] {1} + └── :C[🌳] ├── ·2a95729 ├── ·6861158 ├── ·4f1f248 @@ -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,41 +1397,35 @@ 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#" -⌂:0:C[🌳] <> ✓! on edc4dee -└── ≡:0:C[🌳] on edc4dee {1} - └── :0:C[🌳] +⌂:C[🌳] <> ✓! on edc4dee +└── ≡:C[🌳] on edc4dee {1} + └── :C[🌳] ├── ·2a95729 ├── ·6861158 ├── ·4f1f248 @@ -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,26 +1458,21 @@ 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#" -⌂:0:main[🌳] <> ✓! -└── ≡:0:main[🌳] {1} - └── :0:main[🌳] +⌂:main[🌳] <> ✓! +└── ≡:main[🌳] {1} + └── :main[🌳] ├── ·3686017 ├── ·9725482 └── ·fafd9d0 @@ -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,21 +1501,18 @@ 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#" -⌂: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[📁] "#]] @@ -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,22 +1535,19 @@ 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#" -⌂: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[📁] "#]] @@ -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,27 +1633,25 @@ 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#" -⌂:1:top <> ✓! -└── ≡:1:top {1} - ├── :1:top - └── :0:bottom +⌂:top <> ✓! +└── ≡:top {1} + ├── :top + └── :bottom └── ·960152d ►main[🌳] "#]] @@ -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,21 +1675,19 @@ 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#" -⌂:0:bottom <> ✓! -└── ≡:0:bottom {1} - └── :0:bottom +⌂:bottom <> ✓! +└── ≡:bottom {1} + └── :bottom └── ·960152d ►main[🌳] "#]] @@ -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,24 +1715,21 @@ 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#" -⌂:1:top <> ✓! -└── ≡:1:top {1} - ├── :1:top - ├── :2:middle - └── :0:bottom +⌂:top <> ✓! +└── ≡:top {1} + ├── :top + ├── :middle + └── :bottom └── ·960152d ►main[🌳] "#]] @@ -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,22 +1753,20 @@ 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#" -⌂:1:top <> ✓! -└── ≡:1:top {1} - ├── :1:top - └── :0:bottom +⌂:top <> ✓! +└── ≡:top {1} + ├── :top + └── :bottom └── ·960152d ►main[🌳] "#]] @@ -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,21 +1792,18 @@ 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#" -⌂:0:top <> ✓! -└── ≡:0:top {1} - └── :0:top +⌂:top <> ✓! +└── ≡:top {1} + └── :top ├── ·5cd63e5 └── ·fa91c94 ►bottom, ►main[🌳] @@ -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,23 +1934,95 @@ 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#" +* 👉🏁·960152d (⌂) ►bottom, ►main[🌳], ►other-bottom, ►other-top, ►top +layout: + empty chain anchors: 960152d^ +"#]] + ); + snapbox::assert_data_eq!( + graph_workspace(&ws).to_string(), snapbox::str![[r#" +⌂:top <> ✓! +└── ≡:top {1} + ├── :top + └── :bottom + └── ·960152d ►main[🌳], ►other-bottom, ►other-top + +"#]] + ); + 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())?; -└── 👉►:1[0]:top - └── ►:0[1]:bottom - └── 🏁·960152d (⌂|1) ►main[🌳], ►other-bottom, ►other-top + 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(&graph.into_workspace()?).to_string(), + graph_workspace(&ws).to_string(), snapbox::str![[r#" -⌂:1:top <> ✓! -└── ≡:1:top {1} - ├── :1:top - └── :0:bottom - └── ·960152d ►main[🌳], ►other-bottom, ►other-top +⌂:top <> ✓refs/remotes/origin/master on 86719d5 +└── ≡:top on 86719d5 {1} + ├── :top + └── :bottom + ├── ·96fee4c ►main[🌳] + ├── ·a6448ba + └── ·ff07efd "#]] ); @@ -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/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/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/walk/with_workspace.rs b/crates/but-graph/tests/graph/walk/with_workspace.rs new file mode 100644 index 00000000000..8fe243ce669 --- /dev/null +++ b/crates/but-graph/tests/graph/walk/with_workspace.rs @@ -0,0 +1,8565 @@ +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 (🏘️) +│ └── 📙:feat-2 +└── ≡📙: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-empty-03 + ├── 📙:A-empty-02 + ├── 📙:A-empty-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 + └── 👉📙: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 +│ └── 📙: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 display_stacks = ws.display_stacks()?; + let entrypoint_stack_segment = display_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#" +📕🏘️⚠️:A <> ✓refs/remotes/remote-0/B on fafd9d0 +├── ≡📙:C on fafd9d0 {0} +│ ├── 📙:C +│ ├── 📙:D +│ └── 📙:E +└── ≡📙:F on fafd9d0 {1} + ├── 📙:F + ├── 📙:G + └── 📙:H + +"#]] + ); + + 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 (🏘️) + ├── 📙:D + └── 📙: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 (🏘️) + ├── 📙:D + └── 📙: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 (🏘️) + └── 📙:A + +"#]] + ); + + // 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 (🏘️) + └── 📙:A + +"#]] + ); + + 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, + parents: None, + }], + 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 + └── 📙:B + +"#]] + ); + 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 (🏘️) + └── 📙:A <> origin/A⇣1 + └── 🟣0b6b861 + +"#]] + ); + 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: + 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_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 + 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 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: + empty 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 (🏘️) +└── ≡📙:A on 6fdab32 {0} + └── 📙:A + +"#]] + ); + + 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: + empty 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 (🏘️) +└── ≡📙:A on 6fdab32 {0} + └── 📙:A + +"#]] + ); + 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: 3183e43 f9e2cb7 49d4b34 + 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: 081bae9 3e01e28 + 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: 81d4e38 7163661 + 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-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(()) -} 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..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(|| { @@ -897,8 +896,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 +964,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.set_segment_graph_from_stacks_for_testing(&stacks); + ws } fn deep_eq(a: &HunkAssignment, b: &HunkAssignment) -> bool { @@ -1224,8 +1199,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 +1214,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 +1227,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 +1243,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 +1256,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-hunk-dependency/src/lib.rs b/crates/but-hunk-dependency/src/lib.rs index 225e72a0deb..ca8db0d5ebe 100644 --- a/crates/but-hunk-dependency/src/lib.rs +++ b/crates/but-hunk-dependency/src/lib.rs @@ -149,7 +149,7 @@ pub fn new_stacks_to_input_stacks( workspace: &but_graph::Workspace, ) -> anyhow::Result> { workspace - .stacks + .display_stacks()? .iter() .map(|stack| { let commits = stack diff --git a/crates/but-meta/src/legacy/mod.rs b/crates/but-meta/src/legacy/mod.rs index c143da09787..b7d79a8991f 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,21 +163,22 @@ 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 - .stacks + .display_stacks() + .unwrap_or_default() .iter() .flat_map(|stack| stack.segments.iter()) .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 +213,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 +267,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 +284,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 +303,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 +340,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; @@ -371,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)| { @@ -393,7 +391,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 +408,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 @@ -1298,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 5d0d4c80d2f..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, }], }); @@ -1487,12 +1499,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 +1515,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 +1549,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 "#]] ); @@ -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/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..9cb0f9f0639 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,69 @@ 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, + 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], + trees: ConflictTrees, + sign_commit: SignCommit, +) -> Result { + let mut outcome = repo.merge_trees( + trees.base, + trees.ours, + trees.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, + trees, + 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 +263,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 +278,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 +302,18 @@ 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, - base_tree_id, - ours_tree_id, - theirs_tree_id, + ontos, + ConflictTrees { + base: base_tree_id, + ours: ours_tree_id, + theirs: theirs_tree_id, + }, sign_commit, - )?; - Ok(Some(CherryPickOutcome::ConflictedCommit( - conflicted_commit.detach(), - ))) + ) + .map(Some) } #[derive(Debug, Clone)] @@ -356,21 +366,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 +391,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 +404,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( @@ -465,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(); @@ -478,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; @@ -508,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(), @@ -531,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 9c9a8b8729d..105e95cc203 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. @@ -129,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 0cddbcce6e1..56c896a5003 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,273 @@ 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, 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. + 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); + queue.push(entry.into()); } - - // 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, - } - - 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::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/graph_editor.rs b/crates/but-rebase/src/graph_rebase/graph_editor.rs new file mode 100644 index 00000000000..a7a71e81a78 --- /dev/null +++ b/crates/but-rebase/src/graph_rebase/graph_editor.rs @@ -0,0 +1,1054 @@ +//! 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); + let vacated = self.extract(entry); + self.place(entry, on, carry, below, vacated); + 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; + let vacated = self.extract(entry); + self.place(entry, key, carry, below, vacated); + 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); + 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 + /// 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); + 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 + /// 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); + let vacated = self.extract(entry); + self.place(entry, on, GroupCarry::None, None, vacated); + 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 + '_ { + // 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()) + } + + /// 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)| { + 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) -> 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() { + 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); + } + vacated + } + + /// 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, + 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, + carry, + attach, + ); + } + + /// 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; + } + + /// 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/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..3701ae78ad6 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,9 @@ 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::commit::DateMode; +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 +47,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. @@ -104,11 +104,41 @@ 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. /// - /// 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 +260,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 +297,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 +309,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 +342,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 +377,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 +411,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..bc45e289bf1 100644 --- a/crates/but-rebase/src/graph_rebase/mutate.rs +++ b/crates/but-rebase/src/graph_rebase/mutate.rs @@ -1,239 +1,167 @@ //! 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()), - } - } -} - -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) - } -} +use crate::graph_rebase::selector::{SelectorSet, SomeSelectors, StepRange}; +use crate::graph_rebase::{Editor, Selector, Step, ToReferenceSelector, ToSelector}; -impl From for AnySelector { - fn from(value: gix::refs::FullName) -> Self { - Self::Reference(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 NumberTranslation { + map: HashMap>>, } -impl TryFrom> for SomeSelectors -where - T: Into, -{ - type Error = anyhow::Error; - - fn try_from(value: Vec) -> std::result::Result { - Self::new(value) +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. + 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) +/// 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 ToSelector for gix::Id<'_> { - fn to_selector(&self, editor: &Editor) -> Result { - editor.select_commit(self.detach()) +/// 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 ToReferenceSelector for &gix::refs::FullNameRef { - fn to_reference_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 ToReferenceSelector for gix::refs::FullName { - fn to_reference_selector(&self, editor: &Editor) -> Result { - editor.select_reference(self.as_ref()) - } +/// 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, } -impl ToSelector for &gix::refs::FullNameRef { - fn to_selector(&self, editor: &Editor) -> Result { - editor.select_reference(self) - } +/// 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, } -impl ToSelector for gix::refs::FullName { - fn to_selector(&self, editor: &Editor) -> Result { - editor.select_reference(self.as_ref()) - } +/// 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 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 +182,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,124 +193,182 @@ 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()) + /// 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 } - /// 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()) + /// 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) } - /// 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); - } - _ => {} + 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) + && !from.exclude_from_tracking + && !to.exclude_from_tracking + { + self.history.update_mapping(from.id, to.id); + } + 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) + } - Ok(references) + /// 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) } - /// 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. + /// 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. /// - /// 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) - && !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) + /// `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 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. [`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: SegmentDelimiter, + target: StepRange, children_to_disconnect: SelectorSet, parents_to_disconnect: SelectorSet, - skip_reconnect_step: bool, + reconnect: Reconnect, ) -> Result<()> where 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 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)?; + // 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 +377,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::>>()?, ), }; @@ -407,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" )); } } @@ -416,195 +397,368 @@ 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 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 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 numbers, + )?; + // 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, + ReconnectRefs { + moving_ref_children: &moving_ref_children, + carried_parent_tops: &carried_parent_tops, + }, + &disconnected_parent_edges, + &mut numbers, + 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>, + 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(); + 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 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 = numbers + .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); + numbers.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. + #[expect(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, + reconnect: ReconnectRefs<'_>, + disconnected_parent_edges: &[(usize, PickIndex)], + numbers: &mut NumberTranslation, + 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 translation resolves the + // captured name to the parent number the store uses now. + 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. + 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); + 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); + numbers.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 reconnect.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 = reconnect.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 +767,702 @@ 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)?)?; + let out = self.insert_range_into_impl( + target, + range, + side, + nodes_to_connect, + parent_reparenting_order, + ); + self.verified(out) + } - 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 + 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, + { + 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, + ); + // 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 + // 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. + /// 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. /// - /// Almost always you really want to use `insert` function instead. + /// 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 = 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 new_idx = self.graph.add_node(step); - self.graph.add_edge(new_idx, target.id, Edge { order: 0 }); + let out = self.insert_impl(target, step, side); + self.verified(out) + } - 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); - } + 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 { .. }); + 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); Ok(self.new_selector(new_idx)) } - InsertSide::Below => { - let edges = self + (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 - .edges_directed(target.id, Direction::Outgoing) - .map(|e| (e.id(), e.weight().to_owned(), e.target())) - .collect::>(); - - let new_idx = self.graph.add_node(step); - self.graph.add_edge(target.id, new_idx, Edge { order: 0 }); + .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_target) in edges { - self.graph.remove_edge(edge_id); - self.graph.add_edge(new_idx, edge_target, 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, 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 + .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)) } } } - /// Add an edge to the graph with a desired order. + /// 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(()) + } + + /// 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") + } + + /// [`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(()) + } + + /// 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 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, + 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)); + // 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. /// - /// Bails if there is already an edge from the child to the parent with the - /// same order. - pub fn add_edge( + /// 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. + /// + /// 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 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)?; + 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. - pub fn remove_edges( + /// Sever all edges between a child and parent, returning the (pre-removal, ascending) + /// parent numbers they occupied. Later parents shift down, statements following. + /// + /// `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 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 - .graph - .remove_edge(edge) - .context("BUG: Failed to remove edge")?; + let out = self.detach_impl(child, parent); + self.verified(out) + } - orders.push(weight.order); + /// 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(orders) + Ok(removed) } -} -#[cfg(test)] -mod test { + fn detach_impl( + &mut self, + child: impl ToSelector, + parent: impl ToSelector, + ) -> Result> { + 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 + .parents(child.id) + .iter() + .copied() + .enumerate() + .filter_map(|(parent_number, target)| { + (EditorIndex::from(target) == parent.id).then_some(parent_number) + }) + .collect::>(), + }; - use super::*; + // 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")?; + } - #[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/positions.rs b/crates/but-rebase/src/graph_rebase/positions.rs new file mode 100644 index 00000000000..23dac2765ce --- /dev/null +++ b/crates/but-rebase/src/graph_rebase/positions.rs @@ -0,0 +1,348 @@ +//! 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. 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; + } + } +} + +/// 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 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) { + // 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) { + 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 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())), + }; + 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) { + 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) + ); + } + depth += 1; + if depth > 10_000 { + 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 98742f82ff7..d988e72b161 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::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/ref_ops.rs b/crates/but-rebase/src/graph_rebase/ref_ops.rs new file mode 100644 index 00000000000..6f4eee6d505 --- /dev/null +++ b/crates/but-rebase/src/graph_rebase/ref_ops.rs @@ -0,0 +1,581 @@ +//! 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)>, + /// 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 +/// `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(), + boundary: None, + moved: Vec::new(), + }; + } + 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; + let mut boundary = 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; + boundary = Some(entry); + 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, + boundary, + moved, + } +} + +/// 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); + } +} 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..08fb7f071b3 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) }) } @@ -299,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() @@ -316,7 +375,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 +400,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 +464,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 +482,8 @@ mod tests { snapbox::assert_data_eq!( output, snapbox::str![[r#" -◎ refs/heads/main +◎ refs/heads/main +● 9999999 ├─╮ ● │ aaaaaaa │ ● bbbbbbb @@ -425,12 +501,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 +522,8 @@ mod tests { snapbox::assert_data_eq!( output, snapbox::str![[r#" -◎ refs/heads/main +◎ refs/heads/main +● 9999999 ├─┬─╮ ● │ │ aaaaaaa │ ● │ bbbbbbb @@ -467,14 +545,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 +576,8 @@ mod tests { snapbox::assert_data_eq!( output, snapbox::str![[r#" -◎ refs/heads/main +◎ refs/heads/main +● 9999999 ├─╮ ● │ fffffff ├───┬─╮ @@ -516,13 +596,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 +619,8 @@ mod tests { snapbox::assert_data_eq!( output, snapbox::str![[r#" -◎ refs/heads/main +◎ refs/heads/main +● 9999999 ├─┬─┬─╮ ● │ │ │ aaaaaaa │ ● │ │ bbbbbbb @@ -564,13 +646,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 +666,8 @@ mod tests { snapbox::assert_data_eq!( output, snapbox::str![[r#" -◎ refs/heads/main +◎ refs/heads/main +● 9999999 ├─╮ ● │ a1a1a1a ● │ a2a2a2a @@ -605,13 +689,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 +715,8 @@ mod tests { snapbox::assert_data_eq!( output, snapbox::str![[r#" -◎ refs/heads/main +◎ refs/heads/main +● aaaaaaa ├─╮ ● │ bbbbbbb ├───╮ @@ -655,8 +741,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 +750,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 +773,8 @@ mod tests { snapbox::assert_data_eq!( output, snapbox::str![[r#" -◎ refs/heads/main +◎ refs/heads/main +● 9999999 ├─┬─╮ ● │ │ fffffff ├─────┬─╮ @@ -718,8 +806,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 +815,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 +841,8 @@ mod tests { snapbox::assert_data_eq!( output, snapbox::str![[r#" -◎ refs/heads/main +◎ refs/heads/main +● 9999999 ├─┬─╮ ● │ │ aaaaaaa ● │ │ ddddddd @@ -786,8 +876,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 +885,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 +911,8 @@ mod tests { snapbox::assert_data_eq!( output, snapbox::str![[r#" -◎ refs/heads/main +◎ refs/heads/main +● 1111111 ├─┬─╮ ● │ │ aaaaaaa ● │ │ ddddddd @@ -851,8 +943,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 +953,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 +980,8 @@ mod tests { snapbox::assert_data_eq!( output, snapbox::str![[r#" -◎ refs/heads/main +◎ refs/heads/main +● 9999999 ├─┬─╮ ● │ │ aaaaaaa ● │ │ ddddddd @@ -907,21 +1001,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..fe2447b5e76 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::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,167 +136,254 @@ 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> { - /// Build a graph-based workspace projection framed from this editor. - pub fn graph_workspace(&self) -> Result { - let mut ws = self.graph_workspace_topology()?; +impl Editor<'_, M> { + /// 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 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) } - /// 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()); }; - // 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 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()?; - 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(), + + // 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() }) - } 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(), + .unwrap_or_default(), + }; + + // 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) }) - } - } 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(), }) + .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 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 +394,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 +439,12 @@ 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 { + /// + /// 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()); }; let Some(target_ref_selector) = self.try_select_reference(target_ref.as_ref()) else { @@ -380,7 +471,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 +479,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 +518,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 +530,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 +559,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 +645,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 +657,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 +699,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,94 +726,45 @@ 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)`. -fn divide_workspace_into_stacks( - graph: &StepGraph, - head_not_target: NodeSet, - workspace_commit_ix: StepGraphIndex, -) -> (NodeSet, Vec) { - // Each parent of the workspace commit seeds a stack. - 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()); - } - } - } - } - - // 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`. - let mut deduplicated = vec![]; - while let Some(mut out) = initial_stacks.pop() { - for bix in (0..initial_stacks.len()).rev() { - #[expect(clippy::indexing_slicing)] - if out - .nodes +/// 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(|o| initial_stacks[bix].nodes.contains(o)) - { - let b = initial_stacks.swap_remove(bix); - out.nodes.extend(b.nodes); - out.heads.extend(b.heads); - } - } - deduplicated.push(out); - } - - let mut outside = head_not_target.nodes.clone(); - for stack in &deduplicated { - outside = outside.difference(&stack.nodes).copied().collect(); + .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 + })); } - 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(), - nodes: outside, - }; - - (above_workspace, deduplicated) + entries.extend(additions.into_iter().map(EditorIndex::from)); } #[cfg(test)] 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..6cef18bf96c 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, mutate::Reconnect, 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, - false, + selector::SelectorSet::All, + selector::SelectorSet::All, + Reconnect::Heal, )?; 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, - false, + editor.disconnect_range_from( + range, + selector::SelectorSet::All, + selector::SelectorSet::All, + Reconnect::Heal, )?; 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, - false, + editor.disconnect_range_from( + range, + selector::SelectorSet::All, + selector::SelectorSet::All, + Reconnect::Heal, )?; 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, - false, + editor.disconnect_range_from( + range, + selector::SelectorSet::All, + selector::SelectorSet::All, + Reconnect::Heal, )?; 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])?), - false, + 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])?), + Reconnect::Heal, )?; 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])?), - false, + editor.disconnect_range_from( + range, + selector::SelectorSet::None, + selector::SelectorSet::Some(selector::SomeSelectors::new(vec![parent_one_selector])?), + Reconnect::Heal, )?; 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,52 +706,47 @@ 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, - false, + .disconnect_range_from( + range, + selector::SelectorSet::Some(selector::SomeSelectors::new(vec![child_one_selector])?), + selector::SelectorSet::None, + 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:#}" ); 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,17 +782,17 @@ 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])?), - false, + .disconnect_range_from( + range, + selector::SelectorSet::Some(selector::SomeSelectors::new(vec![child_one_selector])?), + selector::SelectorSet::Some(selector::SomeSelectors::new(vec![child_one_selector])?), + Reconnect::Heal, ) .expect_err("expected disconnect to fail for non-parent selector"); assert!( @@ -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])?), - false, + .disconnect_range_from( + range, + selector::SelectorSet::Some(selector::SomeSelectors::new(vec![parent_one_selector])?), + selector::SelectorSet::Some(selector::SomeSelectors::new(vec![parent_one_selector])?), + Reconnect::Heal, ) .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..d64171f674a 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 detach 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,22 +168,21 @@ 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(); 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(()) } @@ -189,23 +191,22 @@ 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(); 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]); - editor.add_edge(b_selector, a_selector, 0)?; + assert_eq!(editor.detach(b_selector, a_selector)?, vec![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..d29cdaaf934 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, Reconnect}, + 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, + Reconnect::Heal, + ) + .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..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,45 +1,68 @@ //! 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 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}; /// 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 graph = - Graph::from_head(&repo, &*meta, ProjectMeta::default(), standard_options())?.validated()?; - let mut ws = graph.into_workspace()?; - - // 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 { + // 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 editor = Editor::create(&mut ws, &mut *meta, &repo)?; + 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, @@ -60,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 @@ -88,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 @@ -114,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)?, @@ -135,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 @@ -147,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(()) @@ -159,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)?, @@ -173,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 @@ -183,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 "#]] @@ -193,12 +236,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)?, @@ -217,7 +260,11 @@ fn divergent_stacks_sharing_base_merge_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 @@ -229,10 +276,12 @@ 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 ◎ refs/heads/main "#]] ); @@ -242,11 +291,12 @@ fn divergent_stacks_sharing_base_merge_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 @@ -256,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(()) @@ -284,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) @@ -308,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) @@ -321,6 +372,7 @@ fn pegged_with_target() -> Result<()> { ● 120e3a9 c ● a96434e b ● d591dfe a +● 35b8235 base "#]] ); Ok(()) @@ -348,9 +400,17 @@ 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 @@ -363,16 +423,12 @@ fn disjoint_stacks_stay_separate() -> Result<()> { # Stack 1 ◎ refs/heads/stack-a -● 49c06ff A2 -● ff76d2f A1 -◎ refs/heads/main -● 965998b base "#]] ); 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 @@ -380,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 @@ -389,15 +449,15 @@ 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(()) @@ -420,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/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..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,13 +1,15 @@ 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, mutate::Reconnect, 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 +37,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 +74,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 +111,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 +147,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 +198,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 +258,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 +333,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 +378,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 +412,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,27 +449,26 @@ 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, - true, + selector::SelectorSet::All, + selector::SelectorSet::All, + Reconnect::Skip, )?; let ordered = editor.order_commit_selectors_by_parentage([b])?; @@ -514,15 +506,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,14 +539,14 @@ 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, - true, + selector::SelectorSet::All, + selector::SelectorSet::None, + 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/rebase_identities.rs b/crates/but-rebase/tests/rebase/graph_rebase/rebase_identities.rs index 46d426c9308..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,21 +78,20 @@ 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(), snapbox::str![[r#" -⌂:0:main[🌳] <> ✓! -└── ≡:0:main[🌳] {1} - └── :0:main[🌳] +⌂:main[🌳] <> ✓! +└── ≡:main[🌳] {1} + └── :main[🌳] ├── ·120e3a9 ├── ·a96434e ├── ·d591dfe @@ -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..dae4ae51be6 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(); @@ -252,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#" @@ -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..38fd324286e 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,15 +20,18 @@ 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() }; 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.graph, + workspace, stack, commit_flags, stack_id_map.as_mut(), @@ -38,30 +41,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 +79,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/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-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..a6e58cc0096 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, @@ -429,7 +441,7 @@ where if matches!( position, but_workspace::branch::create_reference::Position::Below - ) && segment.commits.is_empty() + ) && segment.tip().is_none() { ( Some( @@ -444,11 +456,9 @@ where let oldest_commit_id = segment .commits .last() - .map(|commit| commit.id) + .copied() .or_else(|| { - workspace - .tip_commit_by_segment_id(segment.id) - .map(|commit| commit.id) + workspace.branch_resting_commit_id_in_display(ref_name.as_ref()) }) .ok_or_else(|| { anyhow::anyhow!( @@ -468,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(), }; @@ -568,7 +578,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 +753,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 +994,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 +1015,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 +1026,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 +1054,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 +1081,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 +1092,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 +1123,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 +1136,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 +1153,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 +1163,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 +1178,7 @@ fn workspace_state_from_rebase( } let outcome = but_workspace::branch::apply( branch.name.as_ref(), - materialized.workspace.clone(), + workspace, repo, materialized.meta, but_workspace::branch::apply::Options { @@ -1167,7 +1186,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 +1207,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 73223ce937e..01db7ea309a 100644 --- a/crates/but-workspace/src/branch/apply.rs +++ b/crates/but-workspace/src/branch/apply.rs @@ -5,6 +5,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)] @@ -94,6 +95,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 { @@ -177,22 +183,21 @@ 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, - refs::{ - FullNameRef, Target, - transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}, - }, + refs::{FullNameRef, Target, transaction::PreviousValue}, }; use tracing::instrument; use crate::{ WorkspaceCommit, branch::{anon_stacks, ensure_no_missing_stacks}, - commit::merge::Tip, - ref_info::WorkspaceExt, + commit::merge::Seed, }; /// Apply `branch` to the given `workspace`, and possibly create the workspace reference in `repo` @@ -219,7 +224,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 { @@ -230,116 +235,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_branch_the_target_or_its_local_tracking_branch(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() - .map(|commit| 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 - .graph - .redo_traversal_with_overlay( - repo, - meta, - Overlay::default().with_entrypoint(commit_to_checkout, ws_ref_name.clone()), - )? - .into_workspace()?; - 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. @@ -368,11 +276,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(); } @@ -400,7 +305,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) @@ -419,45 +324,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) = @@ -488,14 +363,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 @@ -528,11 +400,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( @@ -540,9 +412,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( @@ -553,7 +425,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, @@ -576,48 +448,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.graph, + 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 graph = ws - .graph - .redo_traversal_with_overlay(&in_memory_repo, meta, overlay.clone())?; - - let mut ws = graph.into_workspace()?; + 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() @@ -629,33 +477,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(), @@ -691,47 +527,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.graph, + 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)?; - - 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 - .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()?; + 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, + }; + 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() { @@ -786,6 +599,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 @@ -854,33 +934,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)) }) } @@ -899,54 +969,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) @@ -1065,53 +1101,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 b404c7ae668..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) @@ -332,16 +331,22 @@ 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())?; + // 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 } @@ -355,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, @@ -384,15 +389,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, @@ -430,15 +428,15 @@ 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 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 +458,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(); @@ -550,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 @@ -611,7 +606,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( @@ -704,6 +699,7 @@ pub(super) mod function { .push(WorkspaceStackBranch { ref_name: new_ref.to_owned(), archived: false, + parents: None, }); } // create new @@ -714,6 +710,7 @@ pub(super) mod function { branches: vec![WorkspaceStackBranch { ref_name: new_ref.to_owned(), archived: false, + parents: None, }], }; @@ -750,6 +747,7 @@ pub(super) mod function { WorkspaceStackBranch { ref_name: new_ref.to_owned(), archived: false, + parents: None, }, ); } @@ -765,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 3e1c4aec3da..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,7 +7,8 @@ use anyhow::{Result, bail}; use but_core::{RefMetadata, commit::Headers}; use but_rebase::graph_rebase::{ Editor, LookupStep, SuccessfulRebase, ToSelector, - mutate::{SegmentDelimiter, SelectorSet}, + mutate::Reconnect, + selector::{SelectorSet, StepRange}, }; use crate::graph_manipulation::{EdgeSelection, connect_segment_to_edges, selected_edges_from_set}; @@ -110,19 +111,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 +142,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 +154,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 +168,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,11 +185,11 @@ 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, + editor.disconnect_range_from( + range, children_to_disconnect, parents_to_disconnect, - true, + Reconnect::Skip, )?; // Step 4: Based on the prepared steps, we rebuild the chain. @@ -233,7 +239,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 +252,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..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, - mutate::{SegmentDelimiter, SelectorSet}, - }, +use but_core::{ChangeId, RefMetadata, RepositoryExt}; +use but_rebase::graph_rebase::{ + Editor, LookupStep, Selector, Step, + mutate::Reconnect, + selector::{SelectorSet, StepRange}, }; use crate::graph_manipulation::{ @@ -210,7 +203,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 +227,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 { @@ -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. @@ -333,13 +301,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 +330,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 +349,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> { @@ -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 @@ -413,7 +377,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,19 +401,19 @@ 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, }, 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 2be6c41e118..340e62035d8 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,29 +482,30 @@ 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 { - 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| { + 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::Tip { + crate::commit::merge::Seed { name: None, commit_id: cid, - segment_idx: s.id, }, ) }) - }) - } else { - None - } - }) + } else { + None + } + }) } /// Ensure every metadata stack that should be merged was visible in the graph. @@ -521,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 7d1e0b32199..cab5b19b12f 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, @@ -24,11 +24,62 @@ pub struct Outcome<'ws, '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::SomeSelectors; + 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; @@ -55,13 +106,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 +137,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 +152,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 +168,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,16 +178,16 @@ pub(super) mod function { Some(workspace_head), )?; - editor.disconnect_segment_from( + editor.disconnect_range_from( subject_delimiter.clone(), children_to_disconnect, parents_to_disconnect, - false, + Reconnect::Heal, )?; 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 +229,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)?; @@ -200,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, @@ -229,29 +280,31 @@ 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>, - workspace: but_graph::Workspace, + fn move_branch_in_single_branch_mode<'meta, M: RefMetadata>( + mut successful_rebase: SuccessfulRebase<'meta, M>, + entrypoint: Option, 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); // 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. - let move_requires_graph_update = !subject_segment.commits.is_empty() - || successful_rebase.reference_target(subject_branch_name)? - != successful_rebase.reference_target(target_branch_name)?; + // 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.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() { @@ -300,32 +353,59 @@ 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")?; + // 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(); - 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 needs_commit_surgery { + 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, + 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()?, @@ -344,27 +424,37 @@ 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; - 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() { @@ -378,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() @@ -387,7 +532,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( @@ -397,14 +542,18 @@ pub(super) mod function { Some(workspace_head), )?; - let skip_reconnect_step = source_stack.segments.len() == 1; - editor.disconnect_segment_from( + 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_segment( + editor.insert_range( target_selector, subject_delimiter, but_rebase::graph_rebase::mutate::InsertSide::Above, @@ -424,41 +573,75 @@ 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 .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 { - 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?; @@ -519,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 5024d4dcef4..08d987e3da1 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 +/// [`display_stacks`](but_graph::Workspace::display_stacks). 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) { @@ -84,27 +81,31 @@ 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 { + 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.tip_commit_by_segment_id(s.id).map(|c| (rn, c.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 \ @@ -113,14 +114,11 @@ 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", )?; - 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..f0581e3b9bb 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 + /// [`display_stacks`](but_graph::Workspace::display_stacks) (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_graph::init::Overlay; + 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,12 @@ 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 = ws + .segment_graph + .segment_location_by_name(branch) + .is_some_and(|(stack_idx, _)| ws.entry_marks_stack(stack_idx)); let workspace_tip_was_entrypoint = ws.is_entrypoint(); let Some(workspace_ref_name) = workspace_ref_name else { @@ -255,79 +256,143 @@ 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! // 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", 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 - .graph - .redo_traversal_with_overlay( - repo, + // 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, - Overlay::default() - .with_dropped_references([branch.to_owned()]) - .with_workspace_metadata_override(Some(( - workspace_ref_name.to_owned(), - ws_md.clone(), - ))), - )? - .into_workspace()?; - // 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, - )?; + 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() .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 +403,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 @@ -358,7 +420,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( @@ -374,9 +438,9 @@ 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, + display + .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,19 +451,16 @@ 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 = 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, }), @@ -434,119 +495,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_tips = future_workspace_tips(ws_md, ws, excluded_anonymous_tip_id)?; - let remaining_tip_count = future_workspace_tips.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_tips)?; - 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_tips, &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_tips( - 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.graph, - ); - 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. @@ -562,7 +517,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", @@ -570,6 +525,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, @@ -581,9 +538,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,103 +558,14 @@ 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), + 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_tips: &[crate::commit::merge::Tip], - repo: &gix::Repository, - ) -> anyhow::Result { - let mut tips = future_workspace_tips.to_vec(); - let report_workspace_merge = !tips.is_empty(); - if tips.is_empty() { - tips.push(base_tip_after_unapply(ws, future_workspace_tips)?); - } - let outcome = WorkspaceCommit::from_new_merge_with_tips(tips, &ws.graph, 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_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 { - name: None, - commit_id, - segment_idx, - }) - } - - /// 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, @@ -706,30 +573,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 { + for stack in ws.display_stacks()? { + 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)); } @@ -772,23 +642,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_tips: &[crate::commit::merge::Tip], - ) -> anyhow::Result { - if let Some(tip) = future_workspace_tips.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`. @@ -809,19 +664,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(()) } @@ -842,7 +689,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)); @@ -852,7 +699,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)); } @@ -876,30 +723,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 +760,16 @@ 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(), + // 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 - .tip_commit_by_segment_id(segment_id) - .map(|commit| commit.id) + .branch_resting_commit_id_in_display(ref_info.ref_name.as_ref()) .or(ref_info.commit_id) .with_context(|| { format!( @@ -953,20 +803,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/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..3a3347bd2ee 100644 --- a/crates/but-workspace/src/commit/discard_commit.rs +++ b/crates/but-workspace/src/commit/discard_commit.rs @@ -2,20 +2,17 @@ use anyhow::bail; use but_core::RefMetadata; -use but_rebase::graph_rebase::{ - Editor, Step, SuccessfulRebase, - mutate::{SegmentDelimiter, SelectorSet}, -}; +use but_rebase::graph_rebase::{Editor, SuccessfulRebase}; /// Discard one or more commits in a single rebase operation. /// /// 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 { @@ -24,14 +21,7 @@ pub fn discard_commits<'ws, 'meta, M: RefMetadata>( } count += 1; let (selector, _commit) = editor.find_selectable_commit(commit_id)?; - - let delimiter = SegmentDelimiter { - child: selector, - parent: selector, - }; - - editor.disconnect_segment_from(delimiter, 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/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..bc07cfd363a 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())) ) } } @@ -96,7 +97,6 @@ pub mod merge { RepositoryExt, ref_metadata::{MaybeDebug, WorkspaceCommitRelation}, }; - use but_graph::SegmentIndex; use gix::prelude::ObjectIdExt; use tracing::instrument; @@ -105,14 +105,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 +118,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,51 +169,56 @@ 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, - ) -> ResolvedTips { + anon_stacks: impl IntoIterator, + workspace: &but_graph::Workspace, + ) -> anyhow::Result { let mut missing_stacks = Vec::new(); - let mut tips_with_metadata_slots: 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 graph.segment_and_commit_by_ref_name(stack_tip_name) { - None => { - missing_stacks.push(top_segment.ref_name.to_owned()); - None - } - Some((segment, commit)) => Some(Tip { - name: Some(stack_tip_name.to_owned()), - commit_id: commit.id, - segment_idx: segment.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(); - let named_tips = tips_with_metadata_slots + WorkspaceCommitRelation::Outside => None, + }; + tips_with_metadata_numbers.push(seed); + } + let named_tips = tips_with_metadata_numbers .iter() .flatten() .cloned() @@ -223,56 +226,46 @@ 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(), + 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, - graph: &but_graph::Graph, + 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_sidx: SegmentIndex, - 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, 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 +273,25 @@ 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( @@ -336,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_sidx: sidx, - 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!( @@ -385,96 +345,39 @@ 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 merge = repo.merge_trees( - base_tree_id, - merge.tree.write()?, + *mode = resolve_merge_trial( + workspace, + repo, + &mut merge, + base_commit_id, + hero_commit_id, 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. 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)); - } - - let (stacks, conflicting_stacks) = tips.iter().fold( - (Vec::new(), Vec::new()), - |(mut stacks, mut conflicting_stacks), - ( - mode, - Tip { - 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:?}" - ) + previous_tip = Some((this_tree_id, this_commit_id)); } - 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); } } /// 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 +398,198 @@ 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, + /// 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, - 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() + ) -> 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 base_commit_id = graph - .tip_skip_empty(base_sidx) + } + + 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, + 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 { @@ -575,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) }) @@ -652,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'); @@ -668,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(), @@ -675,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_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..ffcea778b9e 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, Reconnect}, + 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, + Reconnect::Heal, )?; // 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..01e38d03f2e 100644 --- a/crates/but-workspace/src/commit/squash_commits.rs +++ b/crates/but-workspace/src/commit/squash_commits.rs @@ -2,20 +2,16 @@ 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, - mutate::{SegmentDelimiter, SelectorSet}, - }, +use but_rebase::graph_rebase::{ + Editor, LookupStep as _, Selector, Step, SuccessfulRebase, ToCommitSelector, + merge_commit_changes::MergeCommitChangesOutcome, }; /// 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,30 +46,28 @@ 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)?; - 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)) } fn parent_commit_ids( - editor: &Editor<'_, '_, M>, + editor: &Editor<'_, M>, selector: Selector, ) -> Result> { let mut parents = editor.direct_parents(selector)?; @@ -134,12 +128,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,13 +193,7 @@ 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 { - child: commit_selector, - parent: commit_selector, - }; - editor.disconnect_segment_from(delimiter, 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/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..9a90776251c 100644 --- a/crates/but-workspace/src/graph_manipulation.rs +++ b/crates/but-workspace/src/graph_manipulation.rs @@ -2,17 +2,18 @@ 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::{SegmentDelimiter, SelectorSet, SomeSelectors}, + mutate::Reconnect, + 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,17 +23,19 @@ 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>, - source_stack: &Stack, - subject_segment: &StackSegment, +/// as well as the right segment range to move. +pub fn get_disconnect_parameters<'meta, M: RefMetadata>( + editor: &Editor<'meta, M>, + source_stack: &SegmentStack, + subject_segment: &Segment, workspace_head: Option, ) -> anyhow::Result { 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.tip() == subject_segment.tip() + }) .context("BUG: Unable to find subject segment on source stack.")?; let subject_segment_ref_name = subject_segment @@ -42,8 +45,8 @@ pub fn get_disconnect_parameters<'ws, '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 @@ -51,24 +54,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 +95,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, }); @@ -107,8 +110,8 @@ pub fn get_disconnect_parameters<'ws, '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. @@ -124,7 +127,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 +140,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,17 +184,17 @@ 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, }, SelectorSet::None, SelectorSet::All, - true, + Reconnect::Skip, )?; Ok(()) @@ -212,7 +215,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 +249,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 +312,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 +332,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 +346,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 +362,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 +375,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 896087e6411..89e44018d7b 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_in_display(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/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/lib.rs b/crates/but-workspace/src/lib.rs index 77dc9cd30c8..59b02e0e197 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 c491d624cac..bd8daf22015 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. @@ -363,12 +353,11 @@ 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. commits, commits_on_remote, commits_outside, remote_tracking_ref_name, - remote_tracking_branch_segment_id: _, metadata, is_entrypoint, push_status, @@ -378,7 +367,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() { @@ -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,44 +512,36 @@ 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.display_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.remote_names().into_iter().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, @@ -592,8 +555,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", ); @@ -601,7 +563,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)?; } @@ -789,18 +751,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 { @@ -827,9 +786,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..89f1a0d9fce 100644 --- a/crates/but-workspace/src/ui/diff.rs +++ b/crates/but-workspace/src/ui/diff.rs @@ -10,16 +10,13 @@ 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; - 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..95a109306da 100644 --- a/crates/but-workspace/src/upstream_integration.rs +++ b/crates/but-workspace/src/upstream_integration.rs @@ -6,12 +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, RelativeTo, SegmentDelimiter, SelectorSet}, - }, +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; @@ -48,13 +46,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 +145,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 +181,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 +195,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() @@ -217,11 +219,15 @@ pub fn integrate_upstream_with_hints<'ws, '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, )?; @@ -253,12 +259,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); } } } @@ -346,27 +352,26 @@ pub fn integrate_upstream_with_hints<'ws, '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.add_edge( + editor.reparent_edges( workspace_commit_selector, + *parent_selector, target_ref_selector, - *parent_order, )?; } [] 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)?; } _ => {} } @@ -386,16 +391,14 @@ pub fn integrate_upstream_with_hints<'ws, '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)), 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(); @@ -437,13 +440,7 @@ 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. - // 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)?; - } + editor.reparent_edges(child, parent, target_ref_selector)?; } } } @@ -457,23 +454,36 @@ pub fn integrate_upstream_with_hints<'ws, 'meta, M: RefMetadata>( }) } -#[allow(clippy::too_many_arguments)] -fn collect_stacks<'ws, 'meta, M: RefMetadata>( +/// 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<'ws, '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, + editor: &Editor<'meta, M>, + reach: TargetReach, + target: TargetRef<'_>, review_hints: &[ReviewIntegrationHint], ) -> 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,8 +503,8 @@ 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)? { - if from_target_sha.contains(&parent) { + for parent in editor.position_parents(tip)? { + if reach.from_sha.contains(&parent) { continue; } @@ -519,12 +529,13 @@ fn collect_stacks<'ws, '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 }; @@ -545,16 +556,19 @@ 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() { - if from_target_ref.contains(node) { + // 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 reach.from_ref.contains(node) { attrs.historically_integrated = true; } @@ -597,7 +611,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,10 +644,7 @@ 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, + target, )?; if traversed_commits { // The remote-tip check is only an empty-segment fallback. Once @@ -660,30 +671,25 @@ fn collect_stacks<'ws, '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<'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, + target: TargetRef<'_>, ) -> Result { 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(), - target_sha, - target_ref_commit, - ) + && !reference_points_to_target(editor.repo(), parent_ref.as_ref(), target) }) }) { return Ok(false); @@ -692,7 +698,7 @@ fn empty_local_reference_remote_tip_integrated<'ws, '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); } @@ -704,13 +710,13 @@ fn empty_local_reference_remote_tip_integrated<'ws, '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)) } @@ -723,8 +729,7 @@ fn empty_local_reference_remote_tip_integrated<'ws, '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() @@ -732,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) } @@ -761,7 +766,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 +820,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 +862,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 +881,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 +907,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,23 +923,23 @@ 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, }, SelectorSet::All, SelectorSet::All, - false, + Reconnect::Heal, )?; 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..7d55d9a4d64 100644 --- a/crates/but-workspace/src/workspace.rs +++ b/crates/but-workspace/src/workspace.rs @@ -87,14 +87,34 @@ 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( + substrate: &but_graph::Workspace, + rebase: &but_rebase::graph_rebase::SuccessfulRebase<'_, M>, +) -> Result { + substrate.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 ws = editor.graph_workspace()?; + let editor = Editor::create( + workspace.commit_graph(), + workspace.project_meta(), + meta, + repo, + )?; + let ws = editor.graph_workspace(&workspace.segment_graph)?; Ok(DetailedGraphWorkspace { stacks: ws @@ -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-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-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-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/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 2ff3298e352..9540f200ff0 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,13 +73,12 @@ 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#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓! on 3183e43 -└── ≡:2:anon: on 3183e43 - └── :2:anon: +📕🏘️⚠️:gitbutler/workspace[🌳] <> ✓! on 3183e43 +└── ≡:anon: on 3183e43 + └── :anon: ├── ·0d01196 (🏘️) └── ·4979833 (🏘️) @@ -88,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" @@ -136,20 +133,19 @@ 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(), snapbox::str![[r#" -⌂:0:main[🌳] <> ✓! -└── ≡:0:main[🌳] {1} - └── :0:main[🌳] +⌂:main[🌳] <> ✓! +└── ≡:main[🌳] {1} + └── :main[🌳] ├── ·281da94 ├── ·12995d7 └── ·3d57fc1 @@ -190,25 +186,24 @@ 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(), snapbox::str![[r#" -⌂:0:A2 <> ✓! -└── ≡:0:A2 {1} - ├── :0:A2 +⌂:A2 <> ✓! +└── ≡:A2 {1} + ├── :A2 │ └── ·f1889e7 - ├── :1:A1 + ├── :A1 │ └── ·7de99e1 - └── :2:main[🌳] + └── :main[🌳] └── ·3183e43 "#]] @@ -228,18 +223,18 @@ 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(), 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 "#]] @@ -256,14 +251,14 @@ 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#" -⌂:0:A2 <> ✓! -└── ≡:0:A2 {1} - ├── :0:A2 +⌂:A2 <> ✓! +└── ≡:A2 {1} + ├── :A2 │ ├── ·f1889e7 │ └── ·7de99e1 ►A1, ►on-A1 - └── :1:main[🌳] + └── :main[🌳] └── ·3183e43 "#]] @@ -287,7 +282,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,18 +298,17 @@ 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#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓! on e5d0542 +📕🏘️⚠️:gitbutler/workspace[🌳] <> ✓! on e5d0542 "#]] ); // 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#" @@ -326,29 +320,29 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; 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 "#]] ); // 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#" -📕🏘️⚠️: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 "#]] ); @@ -362,7 +356,7 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; let out = but_workspace::branch::unapply( r("refs/heads/A"), &ws, @@ -380,14 +374,14 @@ 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(), snapbox::str![[r#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓! on e5d0542 -└── ≡📙:2:B on e5d0542 {1} - └── 📙:2:B +📕🏘️⚠️:gitbutler/workspace[🌳] <> ✓! on e5d0542 +└── ≡📙:B on e5d0542 {1} + └── 📙:B "#]] ); @@ -412,9 +406,9 @@ 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#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓! on e5d0542 +📕🏘️⚠️:gitbutler/workspace[🌳] <> ✓! on e5d0542 "#]] ); @@ -445,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!( @@ -454,13 +452,13 @@ 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#" -📕🏘️: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 (🏘️) "#]] @@ -470,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 |/ @@ -499,13 +497,13 @@ 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#" -📕🏘️: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 (🏘️) "#]] @@ -515,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 |/ @@ -529,7 +527,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 +535,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 +545,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, @@ -572,11 +569,11 @@ 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#" -⌂: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 "#]] @@ -600,7 +597,7 @@ Outcome { ), )?; snapbox::assert_data_eq!( - out.to_debug(), + &out.to_debug(), snapbox::str![[r#" Outcome { workspace_changed: true, @@ -610,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!( @@ -618,14 +619,14 @@ 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#" -📕🏘️: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 (🏘️) "#]] @@ -633,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,22 +656,26 @@ 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; + )?; + 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#" -📕🏘️⚠️: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 "#]] ); @@ -684,7 +689,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, @@ -698,11 +703,11 @@ 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#" -⌂: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 "#]] ); @@ -718,16 +723,20 @@ 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; + )?; + 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"), @@ -739,7 +748,7 @@ Outcome { ), )?; snapbox::assert_data_eq!( - out.to_debug(), + &out.to_debug(), snapbox::str![[r#" Outcome { workspace_changed: true, @@ -753,11 +762,11 @@ 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#" -⌂: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 "#]] ); @@ -773,23 +782,27 @@ 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; + )?; + 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(), 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 +825,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 "#]] ); @@ -829,7 +842,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,15 +860,14 @@ Outcome { "#]] ); - let ws = graph.into_workspace()?; 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 (🏘️) "#]] @@ -874,9 +886,9 @@ 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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 "#]] ); @@ -886,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 @@ -916,49 +928,48 @@ 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#" -📕🏘️: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 + .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 +├── ≡📙:A on 85efbe4 {2} +│ └── 📙:A │ └── ·09d8e52 (🏘️) -└── ≡📙:4:B on 85efbe4 {3} - └── 📙:4:B +└── ≡📙:B on 85efbe4 {3} + └── 📙:B └── ·c813d8d (🏘️) "#]] - ); - + ); + } Ok((tmp, repo, meta, ws)) } } @@ -987,20 +998,19 @@ 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(), snapbox::str![[r#" -⌂:0:main[🌳] <> ✓! -└── ≡:0:main[🌳] {1} - └── :0:main[🌳] +⌂:main[🌳] <> ✓! +└── ≡:main[🌳] {1} + └── :main[🌳] └── ·3183e43 "#]] @@ -1009,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(), @@ -1030,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(), @@ -1046,16 +1056,16 @@ 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(), snapbox::str![[r#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓! -└── ≡📙:1:feature {2ec} - ├── 📙:1:feature +📕🏘️:gitbutler/workspace[🌳] <> ✓! +└── ≡📙:feature {2ec} + ├── 📙:feature │ └── ·6b40b15 (🏘️) - └── 📙:2:main + └── 📙:main └── ·3183e43 (🏘️) "#]] @@ -1065,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 |/ @@ -1093,7 +1103,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,16 +1125,15 @@ 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; + 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#" -📕🏘️: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 @@ -1166,11 +1175,11 @@ 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#" -⌂: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 "#]] ); @@ -1179,7 +1188,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 +1199,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,25 +1214,24 @@ 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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 3183e43 -├── ≡:4:anon: on 3183e43 -│ └── :4:anon: -│ └── ·d6bdeab (🏘️) -└── ≡📙:5:outside →:3: on 3183e43 {1} - └── 📙:5:outside →:3: - ├── ·5121eb9* - └── ·67c6397 (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 3183e43 +├── ≡📙:outside on 3183e43 {1} +│ └── 📙:outside +│ ├── ·5121eb9* +│ └── ·67c6397 (🏘️) +└── ≡:anon: on 3183e43 + └── :anon: + └── ·d6bdeab (🏘️) "#]] ); let out = but_workspace::branch::apply( r("refs/heads/feature"), - ws, + &ws, &repo, &mut meta, apply_options(), @@ -1240,18 +1249,18 @@ Outcome { ); snapbox::assert_data_eq!( - graph_workspace(&out.workspace).to_string(), + graph_workspace(&out.display_workspace()?).to_string(), snapbox::str![[r#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 3183e43 -├── ≡:5:anon: on 3183e43 -│ └── :5:anon: -│ └── ·d6bdeab (🏘️) -├── ≡📙:3:outside on 3183e43 {1} -│ └── 📙:3:outside +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 3183e43 +├── ≡📙:outside on 3183e43 {1} +│ └── 📙:outside │ ├── ·5121eb9 (🏘️) │ └── ·67c6397 (🏘️) -└── ≡📙:4:feature on 3183e43 {2ec} - └── 📙:4:feature +├── ≡:anon: on 3183e43 +│ └── :anon: +│ └── ·d6bdeab (🏘️) +└── ≡📙:feature on 3183e43 {2ec} + └── 📙:feature └── ·d03b217 (🏘️) "#]] @@ -1261,7 +1270,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,18 +1282,17 @@ 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#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓! on e5d0542 +📕🏘️⚠️:gitbutler/workspace[🌳] <> ✓! on e5d0542 "#]] ); // 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#" @@ -1296,13 +1304,13 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; 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 "#]] ); @@ -1316,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#" @@ -1330,15 +1338,15 @@ 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#" -📕🏘️⚠️: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 "#]] ); @@ -1353,13 +1361,13 @@ 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#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓! on e5d0542 -└── ≡📙:2:A on e5d0542 {41} - └── 📙:2:A +📕🏘️⚠️:gitbutler/workspace[🌳] <> ✓! on e5d0542 +└── ≡📙:A on e5d0542 {41} + └── 📙:A "#]] ); @@ -1379,9 +1387,9 @@ Outcome { unapply_options(), )?; snapbox::assert_data_eq!( - graph_workspace(&out.workspace).to_string(), + graph_workspace(&out.display_workspace()?).to_string(), snapbox::str![[r#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓! on e5d0542 +📕🏘️⚠️:gitbutler/workspace[🌳] <> ✓! on e5d0542 "#]] ); @@ -1398,25 +1406,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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 -├── ≡📙:3:B on 85efbe4 {1} -│ └── 📙:3:B -│ └── ·c813d8d (🏘️) -└── ≡:4:A on 85efbe4 - └── :4:A - └── ·09d8e52 (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡:A on 85efbe4 +│ └── :A +│ └── ·09d8e52 (🏘️) +└── ≡📙:B on 85efbe4 {1} + └── 📙:B + └── ·c813d8d (🏘️) "#]] ); @@ -1431,20 +1438,26 @@ 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#" -📕🏘️⚠️: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 (🏘️) "#]] ); - 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(), + &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 +1489,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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 893d602 -├── ≡:3:C on 893d602 -│ ├── :3:C -│ │ └── ·356de85 (🏘️) -│ └── :5:B -│ └── ·f25f65c (🏘️) -└── ≡:4:A on 893d602 - └── :4:A - └── ·26e45af (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 893d602 +├── ≡:A on 893d602 +│ └── :A +│ └── ·26e45af (🏘️) +└── ≡:C on 893d602 + ├── :C + │ └── ·356de85 (🏘️) + └── :B + └── ·f25f65c (🏘️) "#]] ); @@ -1509,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#" -📕🏘️⚠️: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 + └── :A └── ·26e45af (🏘️) "#]] @@ -1531,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/C", - archived: false, - }, - WorkspaceStackBranch { - ref_name: "refs/heads/B", - archived: false, - }, - ], - workspacecommit_relation: Outside, - }, - WorkspaceStack { - id: 2, - branches: [ - WorkspaceStackBranch { - ref_name: "refs/heads/A", - archived: false, - }, - ], - workspacecommit_relation: Merged, - }, - ], + stacks: [], target_ref: "refs/remotes/origin/main", target_commit_id: Sha1(893d6025c9de29ed75369de967e52a1154bbf0ee), push_remote: None, @@ -1596,7 +1589,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,13 +1602,12 @@ 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#" -⌂:0:main[🌳] <> ✓! -└── ≡:0:main[🌳] {1} - └── :0:main[🌳] +⌂:main[🌳] <> ✓! +└── ≡:main[🌳] {1} + └── :main[🌳] └── ·e5d0542 ►A, ►B "#]] @@ -1624,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#" @@ -1638,13 +1630,13 @@ Outcome { ); snapbox::assert_data_eq!( - graph_workspace(&out.workspace).to_string(), + graph_workspace(&out.display_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 "#]] ); @@ -1659,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 { @@ -1682,17 +1674,17 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; 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 "#]] ); @@ -1713,16 +1705,13 @@ 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#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓! -└── ≡:1:anon: {41} - └── :1:anon: +📕🏘️⚠️:gitbutler/workspace[🌳] <> ✓! +└── ≡:anon: {41} + └── :anon: └── ·e5d0542 (🏘️) ►A, ►B, ►main "#]] @@ -1730,7 +1719,7 @@ Outcome { let out = but_workspace::branch::apply( r("refs/heads/A"), - ws, + &ws, &repo, &mut meta, but_workspace::branch::apply::Options { @@ -1742,19 +1731,19 @@ 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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓! -└── ≡📙:2:A {41} - └── 📙:2:A +📕🏘️:gitbutler/workspace[🌳] <> ✓! +└── ≡📙:A {41} + └── 📙:A └── ·e5d0542 (🏘️) ►B, ►main "#]] @@ -1762,7 +1751,7 @@ Outcome { let out = but_workspace::branch::apply( r("refs/heads/B"), - ws, + &ws, &repo, &mut meta, but_workspace::branch::apply::Options { @@ -1775,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 @@ -1783,15 +1772,17 @@ Outcome { .raw() ); - let ws = out.workspace; + let ws = out.display_workspace()?; 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[🌳] <> ✓! +├── ≡📙:B {42} +│ └── 📙:B +│ └── ·e5d0542 (🏘️) ►main +└── ≡📙:A {41} + └── 📙:A + └── ·e5d0542 (🏘️) ►main "#]] ); @@ -1808,7 +1799,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,13 +1812,12 @@ 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#" -⌂: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 "#]] ); @@ -1835,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#" @@ -1848,13 +1838,13 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; 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 "#]] ); @@ -1869,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#" @@ -1881,15 +1871,15 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; 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 "#]] ); @@ -1905,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") @@ -1928,13 +1917,13 @@ 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#" -📕🏘️⚠️: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 "#]] ); @@ -1955,11 +1944,11 @@ 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#" -⌂: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 "#]] ); @@ -1989,19 +1978,18 @@ 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. let out = but_workspace::branch::apply( r("refs/heads/applied"), - ws, + &ws, &repo, &mut meta, apply_options(), @@ -2051,10 +2039,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 +2074,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 +2100,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" @@ -2127,26 +2114,26 @@ 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 (🏘️) "#]] ); 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 | |/ @@ -2221,10 +2208,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 +2242,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,34 +2268,33 @@ 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(), 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 "#]] ); 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 | |/ @@ -2384,15 +2370,14 @@ 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())?; + but_workspace::branch::apply(r("refs/heads/C"), &ws, &repo, &mut meta, apply_options())?; assert_eq!(out.status, OutcomeStatus::Applied); assert_eq!( @@ -2423,7 +2408,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,30 +2422,29 @@ 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#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓! on e5d0542 +📕🏘️⚠️:gitbutler/workspace[🌳] <> ✓! on e5d0542 "#]] ); // 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(), snapbox::str![[r#" -⌂:1:B <> ✓! -└── ≡:1:B {1} - └── :1:B +⌂:B <> ✓! +└── ≡:B {1} + └── :B └── ·e5d0542 ►A, ►main "#]] @@ -2468,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#" @@ -2481,16 +2465,16 @@ 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(), 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 "#]] ); @@ -2530,52 +2514,53 @@ 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#" -📕🏘️⚠️:1:gitbutler/workspace <> ✓! on e5d0542 -├── ≡📙:2:A on e5d0542 {1} -│ └── 📙:2:A -└── ≡👉📙:3:B[🌳] on e5d0542 {2} - └── 👉📙:3:B[🌳] - -"#]] - ); + )?; + { + // 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 +└── ≡👉📙:B[🌳] on e5d0542 {2} + └── 👉📙: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,60 +2569,60 @@ 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 .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 - snapbox::assert_data_eq!( - graph_workspace(&ws).to_string(), - snapbox::str![[r#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓! on 3183e43 -├── ≡📙:2:C on 3183e43 {43} -│ └── 📙:2:C + { + // 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 │ └── ·aaa195b (🏘️) -└── ≡📙:3:B on 3183e43 {42} - └── 📙:3:B +└── ≡📙:B on 3183e43 {42} + └── 📙:B └── ·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#" -📕🏘️:1:gitbutler/workspace <> ✓! on 3183e43 -├── ≡📙:2:C on 3183e43 {43} -│ └── 📙:2:C + )?; + { + // 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 │ └── ·aaa195b (🏘️) -└── ≡👉📙:0:B[🌳] on 3183e43 {42} - └── 👉📙:0:B[🌳] +└── ≡👉📙:B[🌳] on 3183e43 {42} + └── 👉📙:B[🌳] └── ·f57c528 (🏘️) "#]] - ); + ); + } Ok((tmp, repo, meta, ws)) } @@ -2669,11 +2654,11 @@ 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#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓! -└── ≡📙:1:A {1} - └── 📙:1:A +📕🏘️⚠️:gitbutler/workspace[🌳] <> ✓! +└── ≡📙:A {1} + └── 📙:A └── ·e5d0542 (🏘️) ►main "#]] @@ -2716,11 +2701,11 @@ 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#" -⌂:0:A[🌳] <> ✓! -└── ≡📙:0:A[🌳] {1} - └── 📙:0:A[🌳] +⌂:A[🌳] <> ✓! +└── ≡📙:A[🌳] {1} + └── 📙:A[🌳] └── ·e5d0542 ►main "#]] @@ -2747,21 +2732,20 @@ 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(), 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[🌳] "#]] ); @@ -2788,9 +2772,9 @@ 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#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓! on e5d0542 +📕🏘️⚠️:gitbutler/workspace[🌳] <> ✓! on e5d0542 "#]] ); @@ -2830,11 +2814,11 @@ 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#" -📕🏘️⚠️:1:gitbutler/workspace <> ✓! -└── ≡👉📙:0:B[🌳] {2} - └── 👉📙:0:B[🌳] +📕🏘️⚠️:gitbutler/workspace <> ✓! +└── ≡👉📙:B[🌳] {2} + └── 👉📙:B[🌳] └── ·e5d0542 (🏘️) ►main "#]] @@ -2877,13 +2861,13 @@ 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#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓! -└── ≡📙:1:C {43} - ├── 📙:1:C +📕🏘️⚠️:gitbutler/workspace[🌳] <> ✓! +└── ≡📙:C {43} + ├── 📙:C │ └── ·aaa195b (🏘️) - └── :2:main + └── :main └── ·3183e43 (🏘️) "#]] @@ -2929,13 +2913,13 @@ 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#" -📕🏘️⚠️:1:gitbutler/workspace <> ✓! -└── ≡👉📙:0:B[🌳] {42} - ├── 👉📙:0:B[🌳] +📕🏘️⚠️:gitbutler/workspace <> ✓! +└── ≡👉📙:B[🌳] {42} + ├── 👉📙:B[🌳] │ └── ·f57c528 (🏘️) - └── :2:main + └── :main └── ·3183e43 (🏘️) "#]] @@ -2959,7 +2943,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,15 +2961,14 @@ 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#" -⌂:0:main[🌳] <> ✓! -└── ≡:0:main[🌳] {1} - └── :0:main[🌳] +⌂:main[🌳] <> ✓! +└── ≡:main[🌳] {1} + └── :main[🌳] ├── ·b1540e5 └── ·e31e6ca @@ -2993,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#" @@ -3006,16 +2989,16 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; 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 (🏘️) "#]] @@ -3024,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 @@ -3038,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#" @@ -3051,19 +3034,19 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; 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 (🏘️) "#]] @@ -3072,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 @@ -3117,16 +3100,16 @@ Outcome { "#]] ); - let ws = out.workspace.into_owned(); + let ws = out.display_workspace()?; 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 (🏘️) "#]] @@ -3158,14 +3141,14 @@ 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(), snapbox::str![[r#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓! -└── ≡📙:1:main {1a5} - └── 📙:1:main +📕🏘️⚠️:gitbutler/workspace[🌳] <> ✓! +└── ≡📙:main {1a5} + └── 📙:main ├── ·b1540e5 (🏘️) └── ·e31e6ca (🏘️) @@ -3184,7 +3167,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,43 +3184,42 @@ 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#" -⌂:0:main[🌳] <> ✓! -└── ≡:0:main[🌳] {1} - └── :0:main[🌳] +⌂:main[🌳] <> ✓! +└── ≡:main[🌳] {1} + └── :main[🌳] ├── ·b1540e5 └── ·e31e6ca "#]] ); 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#" -📕🏘️: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 (🏘️) "#]] @@ -3284,13 +3266,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 +3288,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" @@ -3324,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, @@ -3345,7 +3325,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,13 +3342,12 @@ 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#" -⌂: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 "#]] ); @@ -3381,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(), @@ -3403,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 @@ -3411,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#" @@ -3427,16 +3406,16 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; 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 (🏘️) "#]] @@ -3444,7 +3423,7 @@ Outcome { let out = but_workspace::branch::apply( r("refs/heads/A2"), - ws, + &ws, &repo, &mut meta, but_workspace::branch::apply::Options { @@ -3465,18 +3444,18 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; 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 (🏘️) "#]] @@ -3547,14 +3526,14 @@ 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(), 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 (🏘️) "#]] @@ -3578,14 +3557,14 @@ 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(), 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 (🏘️) "#]] @@ -3611,16 +3590,15 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; 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 "#]] ); - // snapbox::assert_data_eq!( visualize_commit_graph_all(&repo)?, snapbox::str![[r#" @@ -3637,7 +3615,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,15 +3632,12 @@ 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#" -⌂:0:DETACHED <> ✓! on 3183e43 -└── ≡:0:anon: on 3183e43 {1} - └── :0:anon: +⌂:DETACHED <> ✓! on 3183e43 +└── ≡:anon: on 3183e43 {1} + └── :anon: └── ·aaa195b ►C "#]] @@ -3709,24 +3684,23 @@ 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#" -⌂:0:DETACHED <> ✓! -└── ≡:0:anon: {1} - ├── :0:anon: +⌂:DETACHED <> ✓! +└── ≡:anon: {1} + ├── :anon: │ └── ·f1889e7 ►A2 - ├── :1:A1 + ├── :A1 │ └── ·7de99e1 - └── :2:main[🌳] + └── :main[🌳] └── ·3183e43 "#]] @@ -3745,7 +3719,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,20 +3736,19 @@ fn detached_head_journey() -> anyhow::Result<()> { "#]] ); - let ws = graph.into_workspace()?; 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 "#]] ); 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(), @@ -3789,14 +3762,14 @@ 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(), 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 (🏘️) "#]] @@ -3817,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(), @@ -3834,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 | |/ @@ -3847,16 +3820,16 @@ Outcome { .raw() ); - let ws = out.workspace; + let ws = out.display_workspace()?; 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 (🏘️) "#]] @@ -3864,7 +3837,7 @@ Outcome { let out = but_workspace::branch::apply( r("refs/heads/A"), - ws, + &ws, &repo, &mut meta, but_workspace::branch::apply::Options { @@ -3885,19 +3858,19 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; 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 (🏘️) "#]] @@ -3906,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 @@ -3936,17 +3909,17 @@ Outcome { "#]] ); - let ws = out.workspace.into_owned(); + let ws = out.display_workspace()?; // A was removed 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 (🏘️) "#]] @@ -3969,14 +3942,14 @@ Outcome { "#]] ); - let ws = out.workspace.into_owned(); + let ws = out.display_workspace()?; // B was removed 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 (🏘️) "#]] @@ -4000,12 +3973,12 @@ 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(), snapbox::str![[r#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓! on 3183e43 +📕🏘️⚠️:gitbutler/workspace[🌳] <> ✓! on 3183e43 "#]] ); @@ -4014,7 +3987,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,24 +4005,23 @@ 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( 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 { @@ -4057,20 +4029,20 @@ 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(), 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 (🏘️) "#]] @@ -4111,14 +4083,14 @@ 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(), snapbox::str![[r#" -⌂:0:A[🌳] <> ✓! on 3183e43 -└── ≡:0:A[🌳] on 3183e43 {1} - └── :0:A[🌳] +⌂:A[🌳] <> ✓! on 3183e43 +└── ≡:A[🌳] on 3183e43 {1} + └── :A[🌳] └── ·49d4b34 "#]] @@ -4171,30 +4143,29 @@ 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, + &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(), 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 "#]] @@ -4216,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 @@ -4227,7 +4198,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,20 +4216,19 @@ 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#" -⌂: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 "#]] ); // 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#" @@ -4271,13 +4241,13 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; 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 "#]] @@ -4286,17 +4256,17 @@ 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#" -📕🏘️: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 {43} + └── 📙:C └── ·f084d61 (🏘️) ►A, ►B "#]] @@ -4305,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 |/ @@ -4321,17 +4291,17 @@ 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#" -📕🏘️: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 {2} + └── 📙:B └── ·f084d61 (🏘️) ►A, ►C "#]] @@ -4341,19 +4311,19 @@ 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#" -📕🏘️: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 {2} + ├── 📙:C + └── 📙:B └── ·f084d61 (🏘️) ►A "#]] @@ -4364,7 +4334,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,20 +4349,19 @@ 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#" -⌂: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 "#]] ); // 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#" @@ -4404,13 +4373,13 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; 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 @@ -4419,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 @@ -4429,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(), @@ -4443,14 +4412,14 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; 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 @@ -4459,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 @@ -4469,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!( @@ -4484,16 +4453,16 @@ 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(), 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 @@ -4503,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 @@ -4512,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!( @@ -4527,18 +4496,18 @@ 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(), 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 "#]] @@ -4547,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 @@ -4556,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!( @@ -4571,20 +4540,20 @@ 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(), 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 (🏘️) "#]] @@ -4593,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 |/ @@ -4628,18 +4597,18 @@ 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(), 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 (🏘️) "#]] @@ -4648,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 @@ -4676,18 +4645,18 @@ 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(), 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 (🏘️) "#]] @@ -4696,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 @@ -4724,18 +4693,17 @@ 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(), snapbox::str![[r#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 -└── ≡📙:5:B on 85efbe4 {41} - ├── 📙:5:B - ├── 📙:6:A - │ └── ·f084d61 (🏘️) - └── 📙:4:E - └── ·7076dee (🏘️) ►D +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +└── ≡📙:B on 85efbe4 {41} + ├── 📙:B + └── 📙:A + ├── ·f084d61 (🏘️) + └── ·7076dee (🏘️) ►D, ►E "#]] ); @@ -4743,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 @@ -4771,12 +4739,12 @@ 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(), snapbox::str![[r#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 "#]] ); @@ -4786,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 @@ -4813,12 +4781,12 @@ 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(), snapbox::str![[r#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 "#]] ); @@ -4828,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 @@ -4857,14 +4825,14 @@ 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(), 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 "#]] ); @@ -4918,7 +4886,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 +4894,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", @@ -4940,35 +4907,35 @@ 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!( 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 (🏘️) "#]] @@ -4978,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 | |_|_|_|/ @@ -5002,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(), @@ -5027,28 +4994,28 @@ 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(), 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 (🏘️) "#]] @@ -5065,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 { @@ -5096,24 +5063,24 @@ 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#" -📕🏘️: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 (🏘️) @@ -5125,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 @@ -5241,7 +5208,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,28 +5228,27 @@ 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; + 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(), 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 (🏘️) "#]] ); 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 -| * 9f5b797 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +| * 0c1287d (HEAD -> gitbutler/workspace) GitButler Workspace Commit | * bf53300 (A) add A |/ | * 0e391b2 (origin/B) add B @@ -5294,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(), @@ -5334,7 +5300,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,13 +5325,12 @@ 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#" -⌂:0:main[🌳] <> ✓! on 85efbe4 -└── ≡:0:main[🌳] {1} - └── :0:main[🌳] +⌂:main[🌳] <> ✓! on 85efbe4 +└── ≡:main[🌳] {1} + └── :main[🌳] "#]] ); @@ -5382,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 { @@ -5391,26 +5356,26 @@ 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!( 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 (🏘️) @@ -5427,16 +5392,16 @@ 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 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 "#]] ); @@ -5446,7 +5411,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,15 +5428,14 @@ 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#" -📕🏘️⚠️: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 "#]] ); @@ -5479,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(), @@ -5498,33 +5462,26 @@ 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#" -📕🏘️⚠️: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 "#]] ); // 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(), @@ -5548,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, @@ -5574,15 +5531,15 @@ 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#" -📕🏘️⚠️: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 "#]] ); @@ -5601,28 +5558,27 @@ 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(), 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 "#]] ); 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(), @@ -5638,26 +5594,25 @@ 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!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓! on e5d0542 +📕🏘️⚠️:gitbutler/workspace[🌳] <> ✓! on e5d0542 "#]] ); // 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, @@ -5674,20 +5629,20 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; 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 "#]] ); // 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#" @@ -5701,40 +5656,39 @@ 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#" -📕🏘️⚠️: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 "#]] ); 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(), 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 "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; let out = but_workspace::branch::unapply( r("refs/heads/A"), &ws, @@ -5753,14 +5707,14 @@ 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(), snapbox::str![[r#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓! on e5d0542 -└── ≡📙:2:B on e5d0542 {42} - └── 📙:2:B +📕🏘️⚠️:gitbutler/workspace[🌳] <> ✓! on e5d0542 +└── ≡📙:B on e5d0542 {42} + └── 📙:B "#]] ); @@ -5789,12 +5743,12 @@ 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(), snapbox::str![[r#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓! on e5d0542 +📕🏘️⚠️:gitbutler/workspace[🌳] <> ✓! on e5d0542 "#]] ); @@ -5810,7 +5764,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 +5776,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,16 +5787,15 @@ 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#" -📕🏘️: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 (🏘️) "#]] @@ -5850,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(), @@ -5865,36 +5818,29 @@ 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#" -📕🏘️: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 (🏘️) "#]] ); // 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(), @@ -5908,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'", @@ -5920,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, @@ -5943,16 +5889,16 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; 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 (🏘️) "#]] @@ -5977,19 +5923,18 @@ 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#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓! -└── ≡:1:anon: - └── :1:anon: +📕🏘️⚠️:gitbutler/workspace[🌳] <> ✓! +└── ≡:anon: + └── :anon: └── ·e5d0542 (🏘️) ►A, ►B, ►main "#]] @@ -5997,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(), @@ -6036,19 +5981,18 @@ 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#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓! -└── ≡:1:anon: - └── :1:anon: +📕🏘️⚠️:gitbutler/workspace[🌳] <> ✓! +└── ≡:anon: + └── :anon: └── ·e5d0542 (🏘️) ►A, ►B, ►main "#]] @@ -6084,21 +6028,20 @@ 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#" -⌂:0:main[🌳] <> ✓! -└── ≡:0:main[🌳] {1} - └── :0:main[🌳] +⌂:main[🌳] <> ✓! +└── ≡:main[🌳] {1} + └── :main[🌳] "#]] ); @@ -6106,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(), @@ -6128,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(), @@ -6146,13 +6089,13 @@ Outcome { "#]] ); - let ws = out.workspace; + let ws = out.display_workspace()?; snapbox::assert_data_eq!( graph_workspace(&ws).to_string(), snapbox::str![[r#" -⌂:0:main[🌳] <> ✓! -└── ≡:0:main[🌳] {1} - └── :0:main[🌳] +⌂:main[🌳] <> ✓! +└── ≡:main[🌳] {1} + └── :main[🌳] "#]] ); @@ -6196,24 +6139,175 @@ 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() } } } 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 c44f98cde1b..e26c4c0368f 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 ✓! on 3183e43 +📕🏘️⚠️:gitbutler/workspace[🌳] <> ✓! on 3183e43 "#]] ); @@ -107,15 +106,14 @@ Single commit, no main remote/target, no ws commit, but ws-reference .as_mut() .expect("always set to have workspace") .sha = gix::hash::Kind::Sha1.null(); - 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#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓! -└── ≡:1:main - └── :1:main +📕🏘️⚠️:gitbutler/workspace[🌳] <> ✓! +└── ≡:main + └── :main └── ·3183e43 (🏘️) "#]] @@ -161,14 +159,13 @@ 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#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 3183e43 +📕🏘️⚠️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 3183e43 "#]] ); @@ -183,13 +180,14 @@ 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#" -📕🏘️⚠️: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 "#]] ); @@ -209,20 +207,21 @@ 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#" -📕🏘️⚠️: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 "#]] ); - // Idempotency + // Idempotency: `None` = nothing changed, the input workspace stays current. let ws = but_workspace::branch::create_reference( b_ref, None, /* anchor */ @@ -231,15 +230,16 @@ 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#" -📕🏘️⚠️: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 "#]] ); @@ -256,16 +256,17 @@ 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#" -📕🏘️⚠️: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 "#]] ); @@ -282,17 +283,18 @@ 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#" -📕🏘️⚠️: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 "#]] ); @@ -301,19 +303,18 @@ 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#" -📕🏘️⚠️: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 "#]] ); @@ -344,16 +345,15 @@ 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#" -📕🏘️: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 (🏘️) @@ -373,17 +373,18 @@ 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!( 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 (🏘️) "#]] @@ -401,18 +402,19 @@ 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#" -📕🏘️: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 "#]] ); @@ -430,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, @@ -438,13 +441,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 "#]] ); @@ -462,20 +465,21 @@ 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!( 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 "#]] ); @@ -493,21 +497,22 @@ 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!( 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 "#]] ); @@ -524,21 +529,22 @@ 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#" -📕🏘️: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 "#]] ); @@ -555,21 +561,22 @@ 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#" -📕🏘️: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 + ├── 📙:below-A + ├── 📙:above-A + ├── 📙:A │ └── ·43f9472 (🏘️) - ├── 📙:8:below-A - ├── 📙:9:below-A-commit - ├── 📙:10:above-bottom + ├── 📙:below-A-commit + ├── 📙:above-bottom │ └── ·6fdab32 (🏘️) - └── 📙:11:bottom + └── 📙:bottom "#]] ); @@ -584,23 +591,24 @@ 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#" -📕🏘️: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 +│ ├── 📙:below-A +│ ├── 📙:above-A +│ ├── 📙:A │ │ └── ·43f9472 (🏘️) -│ ├── 📙:9:below-A -│ ├── 📙:10:below-A-commit -│ ├── 📙:11:above-bottom +│ ├── 📙:below-A-commit +│ ├── 📙:above-bottom │ │ └── ·6fdab32 (🏘️) -│ └── 📙:12:bottom -└── ≡📙:5:B on bce0c5e {42} - └── 📙:5:B +│ └── 📙:bottom +└── ≡📙:B on bce0c5e {42} + └── 📙:B "#]] ); @@ -618,24 +626,25 @@ 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#" -📕🏘️: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 +│ ├── 📙:below-A +│ ├── 📙:above-A +│ ├── 📙:A │ │ └── ·43f9472 (🏘️) -│ ├── 📙:10:below-A -│ ├── 📙:11:below-A-commit -│ ├── 📙:12:above-bottom +│ ├── 📙: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 "#]] ); @@ -655,25 +664,26 @@ 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#" -📕🏘️: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 +│ ├── 📙:below-A +│ ├── 📙:above-A +│ ├── 📙:A │ │ └── ·43f9472 (🏘️) -│ ├── 📙:11:below-A -│ ├── 📙:12:below-A-commit -│ ├── 📙:13:above-bottom +│ ├── 📙: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 "#]] ); @@ -682,27 +692,26 @@ 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#" -📕🏘️: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 +│ ├── 📙:below-A +│ ├── 📙:above-A +│ ├── 📙:A │ │ └── ·43f9472 (🏘️) -│ ├── 📙:11:below-A -│ ├── 📙:12:below-A-commit -│ ├── 📙:13:above-bottom +│ ├── 📙: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 "#]] ); @@ -711,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 @@ -737,16 +746,15 @@ 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#" -📕🏘️⚠️: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 (🏘️) @@ -766,15 +774,16 @@ 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#" -📕🏘️⚠️: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 (🏘️) "#]] @@ -792,20 +801,21 @@ 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 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 "#]] ); @@ -823,20 +833,21 @@ 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!( 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 "#]] ); @@ -854,21 +865,22 @@ 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!( 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 "#]] ); @@ -887,20 +899,21 @@ 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#" -📕🏘️⚠️: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 "#]] ); @@ -917,21 +930,22 @@ 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#" -📕🏘️⚠️: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 "#]] ); @@ -948,21 +962,22 @@ 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#" -📕🏘️⚠️: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 "#]] ); @@ -977,23 +992,24 @@ 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#" -📕🏘️⚠️: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 "#]] ); @@ -1011,24 +1027,25 @@ 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#" -📕🏘️⚠️: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 "#]] ); @@ -1048,25 +1065,26 @@ 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#" -📕🏘️⚠️: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 "#]] ); @@ -1075,27 +1093,26 @@ 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#" -📕🏘️⚠️: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 "#]] ); @@ -1128,16 +1145,15 @@ 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#" -📕🏘️⚠️: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 (🏘️) @@ -1157,17 +1173,18 @@ 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#" -📕🏘️⚠️: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 "#]] ); @@ -1194,20 +1211,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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 3183e43 -├── ≡📙:3:A on 3183e43 {0} -│ └── 📙:3:A -│ └── ·49d4b34 (🏘️) -└── ≡📙:4:B on 3183e43 {1} - └── 📙:4:B - └── ·f57c528 (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 3183e43 +├── ≡📙:B on 3183e43 {1} +│ └── 📙:B +│ └── ·f57c528 (🏘️) +└── ≡📙:A on 3183e43 {0} + └── 📙:A + └── ·49d4b34 (🏘️) "#]] ); @@ -1225,18 +1241,19 @@ 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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 3183e43 -├── ≡📙:3:A on 3183e43 {0} -│ ├── 📙:3:A -│ │ └── ·49d4b34 (🏘️) -│ └── 📙:5:a-bottom -└── ≡📙:4:B on 3183e43 {1} - └── 📙:4:B - └── ·f57c528 (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 3183e43 +├── ≡📙:B on 3183e43 {1} +│ └── 📙:B +│ └── ·f57c528 (🏘️) +└── ≡📙:A on 3183e43 {0} + ├── 📙:A + │ └── ·49d4b34 (🏘️) + └── 📙:a-bottom "#]] ); @@ -1254,20 +1271,21 @@ 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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 3183e43 -├── ≡📙:3:A on 3183e43 {0} -│ ├── 📙:3:A -│ │ └── ·49d4b34 (🏘️) -│ └── 📙:6:a-bottom -└── ≡📙:4:B on 3183e43 {1} - ├── 📙:4:B - │ └── ·f57c528 (🏘️) - └── 📙:5:b-bottom +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 3183e43 +├── ≡📙:B on 3183e43 {1} +│ ├── 📙:B +│ │ └── ·f57c528 (🏘️) +│ └── 📙:b-bottom +└── ≡📙:A on 3183e43 {0} + ├── 📙:A + │ └── ·49d4b34 (🏘️) + └── 📙:a-bottom "#]] ); @@ -1291,15 +1309,14 @@ 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#" -📕🏘️: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 (🏘️) @@ -1320,15 +1337,16 @@ 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#" -📕🏘️: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 (🏘️) "#]] @@ -1349,16 +1367,17 @@ 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#" -📕🏘️: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 (🏘️) "#]] @@ -1377,17 +1396,18 @@ 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#" -📕🏘️: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 (🏘️) "#]] @@ -1406,18 +1426,19 @@ 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#" -📕🏘️: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 (🏘️) "#]] @@ -1435,18 +1456,19 @@ 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#" -📕🏘️: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 (🏘️) "#]] @@ -1456,20 +1478,19 @@ 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#" -📕🏘️: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 (🏘️) "#]] @@ -1500,9 +1521,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( @@ -1513,13 +1533,14 @@ 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#" -📕🏘️⚠️: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 "#]] ); @@ -1537,14 +1558,15 @@ 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#" -📕🏘️⚠️: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 "#]] ); @@ -1561,15 +1583,16 @@ 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#" -📕🏘️⚠️: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 "#]] ); @@ -1596,15 +1619,14 @@ 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#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓! -└── ≡📙:1:main {0} - └── 📙:1:main +📕🏘️⚠️:gitbutler/workspace[🌳] <> ✓! +└── ≡📙:main {0} + └── 📙:main └── ·3183e43 (🏘️) "#]] @@ -1645,14 +1667,15 @@ 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#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓! -└── ≡📙:2:main {0} - ├── 📙:2:main - └── 📙:3:new +📕🏘️⚠️:gitbutler/workspace[🌳] <> ✓! +└── ≡📙:main {0} + ├── 📙:main + └── 📙:new └── ·3183e43 (🏘️) "#]] @@ -1666,19 +1689,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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 3183e43 -├── ≡📙:3:A on 3183e43 {0} -│ └── 📙:3:A -│ └── ·49d4b34 (🏘️) -└── ≡📙:4:B on 3183e43 {1} - └── 📙:4:B - └── ·f57c528 (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 3183e43 +├── ≡📙:B on 3183e43 {1} +│ └── 📙:B +│ └── ·f57c528 (🏘️) +└── ≡📙:A on 3183e43 {0} + └── 📙:A + └── ·49d4b34 (🏘️) "#]] ); @@ -1697,18 +1719,19 @@ 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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 3183e43 -├── ≡📙:3:A on 3183e43 {0} -│ └── 📙:3:A -│ └── ·49d4b34 (🏘️) -└── ≡📙:5:B on 3183e43 {1} - ├── 📙:5:B - └── 📙:6:new - └── ·f57c528 (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 3183e43 +├── ≡📙:B on 3183e43 {1} +│ ├── 📙:B +│ └── 📙:new +│ └── ·f57c528 (🏘️) +└── ≡📙:A on 3183e43 {0} + └── 📙:A + └── ·49d4b34 (🏘️) "#]] ); @@ -1718,9 +1741,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 +1827,17 @@ 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#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣1 on bce0c5e +📕🏘️⚠️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 3183e43 "#]] ); @@ -1876,16 +1901,19 @@ 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#" -📕🏘️⚠️: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 (🏘️) @@ -2033,15 +2061,14 @@ 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#" -📕🏘️: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 (🏘️) "#]] @@ -2053,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" ); @@ -2068,18 +2096,19 @@ 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!( 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 "#]] ); @@ -2099,19 +2128,18 @@ 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#" -⌂:0:main[🌳] <> ✓! -└── ≡:0:main[🌳] {1} - └── :0:main[🌳] +⌂:main[🌳] <> ✓! +└── ≡:main[🌳] {1} + └── :main[🌳] "#]] ); @@ -2145,16 +2173,15 @@ 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(), snapbox::str![[r#" -⌂:0:main[🌳] <> ✓! on c166d42 -└── ≡:0:main[🌳] {1} - └── :0:main[🌳] +⌂:main[🌳] <> ✓! on c166d42 +└── ≡:main[🌳] {1} + └── :main[🌳] "#]] ); @@ -2176,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!( @@ -2254,23 +2279,22 @@ 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#" -⌂:0:A <> ✓! -└── ≡:0:A {1} - ├── :0:A +⌂:A <> ✓! +└── ≡:A {1} + ├── :A │ ├── ·89cc2d3 │ └── ·d79bba9 - └── :1:main[🌳] + └── :main[🌳] └── ·c166d42 "#]] @@ -2307,7 +2331,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,28 +2342,24 @@ fn errors() -> anyhow::Result<()> { ..Options::limited() }, )?; - let ws = graph.into_workspace()?; 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 "#]] ); 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(), @@ -2353,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!( @@ -2378,20 +2398,19 @@ 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(), snapbox::str![[r#" -⌂:0:main[🌳] <> ✓! -└── ≡:0:main[🌳] {1} - └── :0:main[🌳] +⌂:main[🌳] <> ✓! +└── ≡:main[🌳] {1} + └── :main[🌳] ├── ·281da94 ├── ·12995d7 └── ·3d57fc1 @@ -2410,17 +2429,18 @@ 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!( 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 @@ -2448,15 +2468,16 @@ 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#" -⌂:0:main[🌳] <> ✓! -└── ≡:0:main[🌳] {1} - ├── :0:main[🌳] +⌂:main[🌳] <> ✓! +└── ≡:main[🌳] {1} + ├── :main[🌳] │ └── ·281da94 - └── 📙:1:below-main + └── 📙:below-main ├── ·12995d7 └── ·3d57fc1 @@ -2472,17 +2493,18 @@ 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#" -⌂: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 "#]] @@ -2505,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, @@ -2515,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(), @@ -2525,13 +2548,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 "#]] @@ -2549,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(), @@ -2559,13 +2583,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 "#]] @@ -2577,8 +2601,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(); @@ -2608,16 +2632,17 @@ 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(), 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 (🏘️) "#]] @@ -2640,21 +2665,20 @@ 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(), snapbox::str![[r#" -⌂:0:DETACHED <> ✓! -└── ≡:0:anon: {1} - └── :0:anon: +⌂:DETACHED <> ✓! +└── ≡:anon: {1} + └── :anon: ├── ·12995d7 └── ·3d57fc1 @@ -2674,15 +2698,16 @@ 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#" -⌂:0:DETACHED <> ✓! -└── ≡:0:anon: {1} - ├── :0:anon: +⌂:DETACHED <> ✓! +└── ≡:anon: {1} + ├── :anon: │ └── ·12995d7 - └── 📙:1:first + └── 📙:first └── ·3d57fc1 "#]] @@ -2719,15 +2744,16 @@ 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#" -⌂:0:second <> ✓! -└── ≡📙:0:second {1} - ├── 📙:0:second +⌂:second <> ✓! +└── ≡📙:second {1} + ├── 📙:second │ └── ·12995d7 - └── 📙:1:first + └── 📙:first └── ·3d57fc1 "#]] @@ -2752,7 +2778,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,14 +2788,13 @@ 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(), snapbox::str![[r#" -⌂:0:second <> ✓! on 3d57fc1 -└── ≡📙:0:second on 3d57fc1 {1} - └── 📙:0:second +⌂:second <> ✓! on 3d57fc1 +└── ≡📙:second on 3d57fc1 {1} + └── 📙:second └── ·12995d7 "#]] @@ -2800,9 +2825,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)) } @@ -2824,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`. @@ -2847,9 +2875,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[🌳] "#]] ); @@ -2894,8 +2922,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,15 +2954,14 @@ 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#" -⌂:1:main[🌳] <> ✓! on 281da94 -└── ≡:1:main[🌳] {1} - ├── :1:main[🌳] - └── 📙:0:empty-bottom +⌂:main[🌳] <> ✓! on 281da94 +└── ≡:main[🌳] {1} + ├── :main[🌳] + └── 📙:empty-bottom ├── ·281da94 ├── ·12995d7 └── ·3d57fc1 @@ -2980,17 +3006,16 @@ 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#" -⌂: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 +3038,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 @@ -3050,8 +3075,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,16 +3115,15 @@ 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#" -⌂: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 @@ -3216,8 +3239,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, @@ -3234,3 +3256,64 @@ 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, + ); + if let Ok(None) = outcome { + panic!("must not be treated as already-present (silent no-op)"); + } + Ok(()) +} 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 424ccd91896..c6b18fb7273 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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 -├── ≡📙:3:A on 85efbe4 {1} -│ └── 📙:3:A -│ └── ·09d8e52 (🏘️) -└── ≡📙:4:C on 85efbe4 {2} - ├── 📙:4:C - │ └── ·09bc93e (🏘️) - └── 📙:5:B - └── ·c813d8d (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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 @@ -86,14 +87,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 (🏘️) "#]] @@ -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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 -├── ≡📙:3:A on 85efbe4 {1} -│ └── 📙:3:A -│ └── ·09d8e52 (🏘️) -└── ≡📙:4:C on 85efbe4 {2} - ├── 📙:4:C - │ └── ·09bc93e (🏘️) - └── 📙:5:B - └── ·c813d8d (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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!( @@ -212,15 +214,15 @@ 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 -│ │ └── ·f9061ed (🏘️) -│ └── 📙:4:A -│ └── ·09d8e52 (🏘️) -└── ≡📙:5:C on 85efbe4 {2} - └── 📙:5:C - └── ·8e00332 (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 -├── ≡📙:3:A on 85efbe4 {1} -│ └── 📙:3:A -│ └── ·09d8e52 (🏘️) -└── ≡📙:4:C on 85efbe4 {2} - ├── 📙:4:C - │ └── ·09bc93e (🏘️) - └── 📙:5:B - └── ·c813d8d (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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!( @@ -301,13 +304,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 (🏘️) "#]] @@ -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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 -├── ≡📙:3:A on 85efbe4 {1} -│ └── 📙:3:A -│ └── ·09d8e52 (🏘️) -└── ≡📙:4:C on 85efbe4 {2} - ├── 📙:4:C - │ └── ·09bc93e (🏘️) - └── 📙:5:B - └── ·c813d8d (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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!( @@ -392,15 +396,15 @@ 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 -│ └── ·09d8e52 (🏘️) -└── ≡📙:4:B on 85efbe4 {2} - ├── 📙:4:B - │ └── ·de0581e (🏘️) - └── 📙:5:C - └── ·8e00332 (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 -├── ≡📙:3:A on 85efbe4 {1} -│ └── 📙:3:A -│ └── ·09d8e52 (🏘️) -└── ≡📙:4:C on 85efbe4 {2} - ├── 📙:4:C - │ └── ·09bc93e (🏘️) - └── 📙:5:B - └── ·c813d8d (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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!( @@ -481,13 +486,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 (🏘️) "#]] @@ -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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 -├── ≡📙:3:A on 85efbe4 {1} -│ └── 📙:3:A -│ └── ·09d8e52 (🏘️) -└── ≡📙:4:B on 85efbe4 {2} - └── 📙:4:B +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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!( @@ -559,10 +565,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 (🏘️) "#]] @@ -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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 -├── ≡📙:3:A on 85efbe4 {1} -│ └── 📙:3:A -│ └── ·09d8e52 (🏘️) -└── ≡📙:4:B on 85efbe4 {2} - └── 📙:4:B +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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!( @@ -633,11 +640,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 "#]] ); @@ -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,40 +676,40 @@ 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#" -📕🏘️: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 "#]] ); - 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!( 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 "#]] ); @@ -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,41 +743,41 @@ 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#" -📕🏘️: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 "#]] ); - 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!( 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 "#]] ); @@ -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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 -├── ≡📙:3:A on 85efbe4 {1} -│ └── 📙:3:A -│ └── ·09d8e52 (🏘️) -└── ≡📙:4:C on 85efbe4 {2} - ├── 📙:4:C - │ └── ·09bc93e (🏘️) - └── 📙:5:B - └── ·c813d8d (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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,39 +852,40 @@ 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!) 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 -│ └── ·c813d8d (🏘️) -└── ≡📙:4:C on 85efbe4 {1} - ├── 📙:4:C - │ └── ·f2cc60d (🏘️) - └── 📙:3:A - └── ·09d8e52 (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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!( 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 (🏘️) "#]] @@ -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,34 +1034,35 @@ 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#" -📕🏘️: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 (🏘️) "#]] ); - 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!( @@ -1062,11 +1080,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 (🏘️) "#]] @@ -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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣1 on 85efbe4 -├── ≡📙:3:A on 85efbe4 {1} -│ └── 📙:3:A -│ └── ·09d8e52 (🏘️) -└── ≡📙:5:B on 85efbe4 {2} - └── 📙:5:B +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣1 on 85efbe4 +├── ≡📙: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!( @@ -1148,10 +1167,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 (🏘️) "#]] @@ -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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣1 on 85efbe4 -├── ≡📙:3:A on 85efbe4 {1} -│ └── 📙:3:A -│ └── ·09d8e52 (🏘️) -└── ≡📙:5:B on 85efbe4 {2} - └── 📙:5:B +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main⇣1 on 85efbe4 +├── ≡📙: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!( @@ -1230,11 +1250,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 "#]] ); @@ -1243,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()) @@ -1276,12 +1297,909 @@ 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(()) } +/// 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. @@ -1290,7 +2208,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 +2277,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 +2374,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). @@ -1477,7 +2397,7 @@ mod single_branch_mode { stack_id_for_name, None, )? - .into_owned(); + .expect("the workspace changed"); } Ok((tmp, repo, meta, project_meta)) @@ -1490,19 +2410,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 +2452,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 +2462,7 @@ mod single_branch_mode { .. } = but_workspace::branch::move_branch( editor, + &ws, r("refs/heads/empty-bottom"), r("refs/heads/empty-top"), )?; @@ -1562,20 +2486,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 +2520,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 +2528,7 @@ mod single_branch_mode { .. } = but_workspace::branch::move_branch( editor, + &ws, r("refs/heads/empty-bottom"), r("refs/heads/empty-top"), )?; @@ -1624,17 +2552,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 +2572,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 +2874,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 +2886,7 @@ mod single_branch_mode { .. } = but_workspace::branch::move_branch( editor, + &ws, r("refs/heads/empty-top"), r("refs/heads/base"), )?; @@ -1920,17 +2926,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 +2943,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 +2973,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"), )?; @@ -1991,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/branch/remove_reference.rs b/crates/but-workspace/tests/workspace/branch/remove_reference.rs index 82125bcb6ed..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,12 +35,11 @@ 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(), snapbox::str![[r#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓! on 3183e43 +📕🏘️⚠️:gitbutler/workspace[🌳] <> ✓! on 3183e43 "#]] ); @@ -83,11 +82,11 @@ 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#" -📕🏘️⚠️:0:gitbutler/workspace[🌳] <> ✓! on 3183e43 +📕🏘️⚠️:gitbutler/workspace[🌳] <> ✓! on 3183e43 "#]] ); @@ -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,15 +119,14 @@ 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#" -📕🏘️⚠️: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 (🏘️) "#]] @@ -151,13 +149,13 @@ 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#" -📕🏘️⚠️: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 (🏘️) @@ -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,20 +198,19 @@ 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#" -📕🏘️: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 +235,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 +264,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 (🏘️) @@ -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,18 +319,17 @@ 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#" -📕🏘️⚠️: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 +350,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 "#]] ); @@ -373,18 +369,18 @@ 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#" -📕🏘️⚠️: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 +403,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 "#]] ); @@ -459,12 +455,12 @@ 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(), 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..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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 -├── ≡📙:3:A on 85efbe4 {1} -│ └── 📙:3:A -│ └── ·09d8e52 (🏘️) -└── ≡📙:4:C on 85efbe4 {2} - ├── 📙:4:C - │ └── ·09bc93e (🏘️) - └── 📙:5:B - └── ·c813d8d (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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 @@ -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 (🏘️) "#]] @@ -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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 -├── ≡📙:3:A on 85efbe4 {1} -│ └── 📙:3:A -│ └── ·09d8e52 (🏘️) -└── ≡📙:4:C on 85efbe4 {2} - ├── 📙:4:C - │ └── ·09bc93e (🏘️) - └── 📙:5:B - └── ·c813d8d (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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 @@ -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 -│ └── ·09d8e52 (🏘️) -├── ≡📙:4:C on 85efbe4 {2} -│ └── 📙:4:C +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙:C on 85efbe4 {2} +│ └── 📙:C │ └── ·8e00332 (🏘️) -└── ≡📙:5:B on 85efbe4 {3} - └── 📙:5:B +├── ≡📙: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 -├── ≡📙:3:A on 85efbe4 {1} -│ └── 📙:3:A -│ └── ·09d8e52 (🏘️) -└── ≡📙:4:C on 85efbe4 {2} - ├── 📙:4:C - │ └── ·09bc93e (🏘️) - └── 📙:5:B - └── ·c813d8d (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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!( @@ -271,15 +271,15 @@ 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 -│ └── ·09d8e52 (🏘️) -└── ≡📙:4:C on 85efbe4 {2} - ├── 📙:4:C - │ └── ·09bc93e (🏘️) - └── 📙:5:B - └── ·c813d8d (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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,26 +305,26 @@ 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#" -📕🏘️: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 (🏘️) "#]] ); - 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 @@ -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 (🏘️) "#]] @@ -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,26 +384,26 @@ 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#" -📕🏘️: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 (🏘️) "#]] ); - 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 @@ -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 (🏘️) "#]] @@ -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,25 +464,25 @@ 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#" -📕🏘️: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 (🏘️) "#]] ); - 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 @@ -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 "#]] ); @@ -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,25 +541,25 @@ 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#" -📕🏘️: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 (🏘️) "#]] ); - 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 @@ -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/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 af85d23c1d0..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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 -├── ≡📙:3:A on 85efbe4 {1} -│ └── 📙:3:A -│ └── ·09d8e52 (🏘️) -└── ≡📙:4:C on 85efbe4 {2} - ├── 📙:4:C - │ └── ·09bc93e (🏘️) - └── 📙:5:B - └── ·c813d8d (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 -├── ≡📙:3:A on 85efbe4 {1} -│ └── 📙:3:A -│ └── ·09d8e52 (🏘️) -└── ≡📙:5:C on 85efbe4 {2} - ├── 📙:5:C - └── 📙:6:B - └── ·c813d8d (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 -├── ≡📙:3:A on 85efbe4 {1} -│ └── 📙:3:A -│ └── ·09d8e52 (🏘️) -└── ≡📙:4:C on 85efbe4 {2} - ├── 📙:4:C - │ └── ·09bc93e (🏘️) - └── 📙:5:B - └── ·c813d8d (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 -├── ≡📙:3:A on 85efbe4 {1} -│ └── 📙:3:A -│ └── ·09d8e52 (🏘️) -└── ≡📙:4:C on 85efbe4 {2} - ├── 📙:4:C - │ └── ·8e00332 (🏘️) - └── 📙:5:B +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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..a96614e113e 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, )?; @@ -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" }, ], @@ -110,7 +111,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 +120,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()?), )?; @@ -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 @@ -214,7 +216,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 +224,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 +241,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, )?; @@ -250,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 }, @@ -299,7 +300,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 +310,7 @@ Outcome { let out = WorkspaceCommit::from_new_merge_with_metadata( &to_stacks(stacks), None, - &graph, + &ws, &repo, Some("refs/heads/conflict-hero".try_into()?), )?; @@ -317,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" }, @@ -363,7 +364,7 @@ Outcome { let out = WorkspaceCommit::from_new_merge_with_metadata( &to_stacks(stacks), None, - &graph, + &ws, &repo, None, )?; @@ -371,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" }, @@ -398,7 +399,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 +432,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 +450,7 @@ Outcome { let out = WorkspaceCommit::from_new_merge_with_metadata( &to_stacks(stacks), None, - &graph, + &ws, &repo, None, )?; @@ -457,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" }, @@ -512,7 +513,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 +523,7 @@ Outcome { let out = WorkspaceCommit::from_new_merge_with_metadata( &to_stacks(stacks), None, - &graph, + &ws, &repo, None, )?; @@ -530,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" }, @@ -561,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 @@ -599,7 +601,7 @@ fc2bf71 let out = WorkspaceCommit::from_new_merge_with_metadata( &to_stacks(stacks), None, - &graph, + &ws, &repo, Some("refs/heads/conflict-C2".try_into()?), )?; @@ -608,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" }, @@ -638,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 @@ -676,7 +679,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()?), )?; @@ -684,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" }, @@ -761,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_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 c9451581f99..5b9c591a6b9 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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 -├── ≡📙:3:A on 85efbe4 {1} -│ └── 📙:3:A -│ └── ·09d8e52 (🏘️) -└── ≡📙:4:C on 85efbe4 {2} - ├── 📙:4:C - │ └── ·09bc93e (🏘️) - └── 📙:5:B - └── ·c813d8d (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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); @@ -114,15 +113,15 @@ 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 -│ ├── ·f2cc60d (🏘️) -│ └── ·09d8e52 (🏘️) -└── ≡📙:5:C on 85efbe4 {2} - ├── 📙:5:C - └── 📙:6:B - └── ·c813d8d (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 -├── ≡📙:3:A on 85efbe4 {1} -│ └── 📙:3:A -│ └── ·09d8e52 (🏘️) -└── ≡📙:4:C on 85efbe4 {2} - ├── 📙:4:C - │ └── ·09bc93e (🏘️) - └── 📙:5:B - └── ·c813d8d (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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); @@ -228,15 +226,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 -│ ├── ·f9061ed (🏘️) -│ └── ·09d8e52 (🏘️) -└── ≡📙:4:C on 85efbe4 {2} - ├── 📙:4:C - │ └── ·8e00332 (🏘️) - └── 📙:5:B +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 -├── ≡📙:3:A on 85efbe4 {1} -│ └── 📙:3:A -│ └── ·09d8e52 (🏘️) -└── ≡📙:4:C on 85efbe4 {2} - ├── 📙:4:C - │ └── ·09bc93e (🏘️) - └── 📙:5:B - └── ·c813d8d (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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); @@ -340,15 +337,15 @@ 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 -│ ├── ·2506923 (🏘️) -│ └── ·8e00332 (🏘️) -└── ≡📙:5:C on 85efbe4 {2} - ├── 📙:5:C - └── 📙:6:B - └── ·c813d8d (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 -├── ≡📙:3:A on 85efbe4 {1} -│ └── 📙:3:A -│ └── ·09d8e52 (🏘️) -└── ≡📙:4:C on 85efbe4 {2} - ├── 📙:4:C - │ └── ·09bc93e (🏘️) - └── 📙:5:B - └── ·c813d8d (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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); @@ -454,15 +450,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 -│ ├── ·4dfe841 (🏘️) -│ └── ·c813d8d (🏘️) -└── ≡📙:4:C on 85efbe4 {2} - ├── 📙:4:C - │ └── ·8e00332 (🏘️) - └── 📙:5:B +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 -├── ≡📙:3:A on 85efbe4 {1} -│ └── 📙:3:A -│ └── ·09d8e52 (🏘️) -└── ≡📙:4:C on 85efbe4 {2} - ├── 📙:4:C - │ └── ·09bc93e (🏘️) - └── 📙:5:B - └── ·c813d8d (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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); @@ -559,15 +554,15 @@ 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 - │ ├── ·148f8f3 (🏘️) - │ └── ·09bc93e (🏘️) - └── 📙:4:B - └── ·c813d8d (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 -├── ≡📙:3:A on 85efbe4 {1} -│ └── 📙:3:A -│ └── ·09d8e52 (🏘️) -└── ≡📙:4:C on 85efbe4 {2} - ├── 📙:4:C - │ └── ·09bc93e (🏘️) - └── 📙:5:B - └── ·c813d8d (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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); @@ -673,15 +667,15 @@ 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 - │ └── ·ad476a8 (🏘️) - └── 📙:4:B - ├── ·f9061ed (🏘️) - └── ·09d8e52 (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 -├── ≡📙:3:A on 85efbe4 {1} -│ └── 📙:3:A -│ └── ·09d8e52 (🏘️) -└── ≡📙:4:B on 85efbe4 {2} - └── 📙:4:B +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙: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(); @@ -765,12 +758,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 -├── ≡📙:4:A on 85efbe4 {1} -│ └── 📙:4:A -└── ≡📙:3:B on 85efbe4 {2} - └── 📙:3:B - └── ·09d8e52 (🏘️) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 85efbe4 +├── ≡📙:B on 85efbe4 {2} +│ └── 📙:B +│ └── ·09d8e52 (🏘️) +└── ≡📙:A on 85efbe4 {1} + └── 📙:A "#]] ); @@ -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,23 +786,22 @@ 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#" -⌂: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 "#]] ); - 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(); @@ -854,13 +846,15 @@ 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[🌳] - │ ├── ·c9f444c ►two - │ └── ·16fd221 - └── :3:one - └── ·8b426d0 +⌂:three[🌳] <> ✓! +└── ≡:three[🌳] {1} + ├── :three[🌳] + ├── :two <> origin/two⇡1 + │ └── ·c9f444c + ├── :origin/two + │ └── ❄16fd221 + └── :one + └── ❄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,23 +885,22 @@ 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#" -⌂: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 "#]] ); - 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,23 +970,22 @@ 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#" -⌂: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 "#]] ); - 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,23 +1046,22 @@ 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#" -⌂: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 "#]] ); - 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); @@ -1124,13 +1115,15 @@ 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[🌳] - │ ├── ·09ad3ca ►two +⌂:three[🌳] <> ✓! +└── ≡:three[🌳] {1} + ├── :three[🌳] + ├── :two <> origin/two⇡2⇣1 + │ ├── 🟣16fd221 + │ ├── ·09ad3ca │ └── ·0c38dd9 - └── :2:one - └── ·8b426d0 + └── :one + └── ❄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 acb20953ad4..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 @@ -488,12 +484,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 (🏘️) "#]] @@ -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 @@ -533,11 +530,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 (🏘️) "#]] @@ -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 @@ -574,12 +569,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 (🏘️) "#]] @@ -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 @@ -619,12 +615,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 "#]] ); @@ -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..ccf7de61651 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")], @@ -941,7 +927,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 +935,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..fc482c95bc7 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, @@ -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 @@ -48,9 +72,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 +99,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 +108,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)) } @@ -358,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#" @@ -401,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#" @@ -433,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#" @@ -458,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(()) @@ -474,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#" @@ -490,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(()) @@ -509,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#" @@ -532,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(()) @@ -552,8 +594,12 @@ 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<()> { - let (_repo, detailed) = detailed("workspace-two-stacks", Some("refs/heads/main"))?; +fn divergent_stacks_sharing_excluded_base_stay_separate_with_target() -> Result<()> { + let (_repo, detailed) = detailed( + "workspace-two-stacks", + Some("refs/heads/main"), + &[&["stack-a"], &["stack-b"]], + )?; snapbox::assert_data_eq!( render(&detailed), snapbox::str![[r#" @@ -561,17 +607,21 @@ 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 +◎ 3 refs/heads/main linear ref=0 rows=[0, 1, 2] - linear ref=3 rows=[3, 4, 5] - linear ref=6 rows=[6] + linear ref=3 rows=[3] reference ref=0 rows=[0, 1, 2] - reference ref=3 rows=[3, 4, 5] - reference ref=6 rows=[6] + 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(()) @@ -581,7 +631,7 @@ fn divergent_stacks_sharing_base_merge_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#" @@ -611,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#" @@ -638,14 +692,8 @@ fn disjoint_stacks_stay_separate() -> Result<()> { # Stack 1 ◎ 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] + linear ref=0 rows=[0] + reference ref=0 rows=[0] "#]] ); Ok(()) @@ -654,18 +702,15 @@ 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#" # 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 @@ -674,6 +719,13 @@ fn disjoint_stacks_stay_separate_with_target() -> Result<()> { 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 cb7021b B2 +● 2 ce3278a B1 + linear ref=0 rows=[0, 1, 2] + reference ref=0 rows=[0, 1, 2] "#]] ); Ok(()) @@ -685,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#" @@ -725,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#" @@ -754,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#" @@ -774,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(()) @@ -797,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#" @@ -818,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(()) @@ -847,12 +901,18 @@ 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 "#]] @@ -889,6 +949,8 @@ 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 "#]] @@ -899,6 +961,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 "#]] ); @@ -929,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 "#]] ); @@ -956,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 "#]] ); @@ -984,12 +1045,15 @@ 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 f1e7451 add C1 state=integrated +# Stack 1 b38b04b add B1 state=local "#]] ); @@ -1016,12 +1080,15 @@ 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 f1e7451 add C1 state=integrated +# Stack 1 b38b04b add B1 state=local "#]] ); @@ -1046,14 +1113,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 +1224,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 +1233,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#" @@ -1185,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#" @@ -1195,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/mod.rs b/crates/but-workspace/tests/workspace/ref_info/mod.rs index d32c2e8803e..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( @@ -166,7 +166,6 @@ RefInfo { base: None, segments: [ ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "►main[🌳]", remote_tracking_ref_name: "None", commits: [], @@ -180,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, @@ -195,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 { @@ -220,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", @@ -261,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, @@ -274,7 +271,6 @@ RefInfo { base: None, segments: [ ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "None", remote_tracking_ref_name: "None", commits: [ @@ -290,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, @@ -305,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#" [] @@ -326,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( @@ -354,7 +348,6 @@ RefInfo { base: None, segments: [ ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "►main[🌳]", remote_tracking_ref_name: "None", commits: [ @@ -371,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, @@ -385,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 { @@ -461,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( @@ -489,7 +480,6 @@ RefInfo { base: None, segments: [ ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "►main[🌳]", remote_tracking_ref_name: "None", commits: [ @@ -514,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, @@ -528,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 { @@ -605,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( @@ -633,7 +621,6 @@ RefInfo { base: None, segments: [ ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "►main[🌳]", remote_tracking_ref_name: "None", commits: [ @@ -646,7 +633,6 @@ RefInfo { base: "344e320", }, ref_info::ui::Segment { - id: NodeIndex(1), ref_name: "►nine", remote_tracking_ref_name: "None", commits: [ @@ -661,7 +647,6 @@ RefInfo { base: "c4f2a35", }, ref_info::ui::Segment { - id: NodeIndex(2), ref_name: "►six", remote_tracking_ref_name: "None", commits: [ @@ -676,7 +661,6 @@ RefInfo { base: "281da94", }, ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "►three", remote_tracking_ref_name: "None", commits: [ @@ -690,7 +674,6 @@ RefInfo { base: "3d57fc1", }, ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "►one", remote_tracking_ref_name: "None", commits: [ @@ -706,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, @@ -722,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 75c700e4526..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 @@ -56,7 +56,6 @@ Ok( base: None, segments: [ ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "►main[🌳]", remote_tracking_ref_name: "None", commits: [], @@ -70,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, @@ -133,7 +130,6 @@ Ok( base: None, segments: [ ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "►main[🌳]", remote_tracking_ref_name: "None", commits: [], @@ -147,15 +143,6 @@ Ok( }, ], target_ref: None, - target_commit: Some( - TargetCommit { - commit_id: Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), - segment_index: NodeIndex(0), - }, - ), - lower_bound: Some( - NodeIndex(0), - ), is_managed_ref: false, is_managed_commit: false, ancestor_workspace_commit: None, @@ -221,7 +208,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,19 +225,9 @@ 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( - NodeIndex(0), - ), is_managed_ref: false, is_managed_commit: false, ancestor_workspace_commit: None, @@ -300,7 +276,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,19 +293,9 @@ 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( - NodeIndex(0), - ), is_managed_ref: false, is_managed_commit: false, ancestor_workspace_commit: None, @@ -395,19 +360,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -477,7 +432,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "►S1", remote_tracking_ref_name: "None", commits: [], @@ -495,19 +449,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -574,7 +518,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "►S1", remote_tracking_ref_name: "None", commits: [ @@ -594,19 +537,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -654,7 +587,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "►S1", remote_tracking_ref_name: "None", commits: [ @@ -674,19 +606,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -756,7 +678,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "►S1", remote_tracking_ref_name: "refs/remotes/origin/S1", commits: [ @@ -776,19 +697,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -862,7 +773,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "►S1", remote_tracking_ref_name: "refs/remotes/origin/S1", commits: [ @@ -883,19 +793,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -968,7 +868,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "►S1", remote_tracking_ref_name: "refs/remotes/origin/S1", commits: [ @@ -989,19 +888,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -1079,7 +968,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "►S1", remote_tracking_ref_name: "refs/remotes/origin/S1", commits: [ @@ -1100,19 +988,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -1200,7 +1078,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "►S1", remote_tracking_ref_name: "refs/remotes/origin/S1", commits: [ @@ -1224,19 +1101,9 @@ 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( - NodeIndex(5), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -1330,7 +1197,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "►S1", remote_tracking_ref_name: "refs/remotes/origin/S1", commits: [ @@ -1355,7 +1221,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(5), ref_name: "►local", remote_tracking_ref_name: "None", commits: [ @@ -1368,7 +1233,6 @@ Ok( base: "de02b20", }, ref_info::ui::Segment { - id: NodeIndex(6), ref_name: "►local-bottom", remote_tracking_ref_name: "None", commits: [ @@ -1388,19 +1252,9 @@ 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( - 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 a0875d5bd3a..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 @@ -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,19 +90,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -175,7 +164,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "►A", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -196,19 +184,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -280,7 +258,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "►A", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -303,19 +280,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -396,7 +363,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(5), ref_name: "►A", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -417,19 +383,9 @@ 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( - NodeIndex(3), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -497,7 +453,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "►A", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -518,19 +473,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -608,7 +553,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(5), ref_name: "►A", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -629,19 +573,9 @@ 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( - NodeIndex(3), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -710,7 +644,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "►A", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -732,19 +665,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -817,7 +740,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "►A", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -837,19 +759,9 @@ 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( - 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 a043938a5fc..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 @@ -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,19 +90,9 @@ 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( - NodeIndex(5), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -180,7 +169,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "►A", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -201,19 +189,9 @@ 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( - NodeIndex(5), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -287,7 +265,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "►A", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -308,19 +285,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -397,7 +364,6 @@ Ok( ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "►A", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -418,19 +384,9 @@ 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( - 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 55a6d06c03b..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,26 +582,25 @@ 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#" -📕🏘️: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,8 +608,7 @@ StackDetails { snapbox::assert_data_eq!( ws.to_debug(), snapbox::str![[r#" -Workspace(📕🏘️:0:gitbutler/workspace[🌳] <> ✓!) { - id: 0, +Workspace(📕🏘️:gitbutler/workspace[🌳] <> ✓!) { kind: Managed { ref_info: RefInfo { ref_name: FullName( @@ -628,9 +626,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 +639,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 +727,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(5), ref_name: "►B", remote_tracking_ref_name: "None", commits: [ @@ -746,7 +743,6 @@ RefInfo { base: "09d8e52", }, ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "►A", remote_tracking_ref_name: "None", commits: [ @@ -766,19 +762,9 @@ 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( - 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 54e2fc0e728..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 @@ -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,19 +259,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -319,7 +308,6 @@ RefInfo { ), segments: [ 👉ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "►A", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -341,19 +329,9 @@ 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( - NodeIndex(3), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -404,7 +382,6 @@ RefInfo { ), segments: [ 👉ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "►A", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -426,19 +403,9 @@ 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( - NodeIndex(3), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -505,7 +472,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 +484,6 @@ RefInfo { base: "f504e38", }, ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "►A", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -539,19 +504,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -692,7 +647,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 +659,6 @@ RefInfo { base: "f504e38", }, ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "►A", remote_tracking_ref_name: "None", commits: [ @@ -718,7 +671,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,19 +690,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -820,7 +762,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 +774,6 @@ RefInfo { base: "0ee3a9e", }, ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "►A", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -853,19 +793,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -944,7 +874,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: [ @@ -954,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", }, ], @@ -964,19 +905,9 @@ 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( - NodeIndex(4), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -1027,7 +958,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 +970,6 @@ RefInfo { base: "0ee3a9e", }, ref_info::ui::Segment { - id: NodeIndex(5), ref_name: "►A", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -1062,19 +991,9 @@ 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( - NodeIndex(3), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -1138,7 +1057,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "►B", remote_tracking_ref_name: "None", commits: [ @@ -1151,7 +1069,6 @@ RefInfo { base: "1818c17", }, ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "►A", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -1171,19 +1088,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -1228,7 +1135,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "►B", remote_tracking_ref_name: "None", commits: [ @@ -1241,7 +1147,6 @@ RefInfo { base: "1818c17", }, ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "►A", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -1261,19 +1166,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -1343,7 +1238,6 @@ RefInfo { ), segments: [ 👉ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "►A", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -1368,19 +1262,9 @@ 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( - NodeIndex(3), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -1451,7 +1335,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(5), ref_name: "►lane", remote_tracking_ref_name: "None", commits: [], @@ -1472,7 +1355,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "►advanced-lane-2", remote_tracking_ref_name: "None", commits: [ @@ -1495,7 +1377,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "►advanced-lane", remote_tracking_ref_name: "None", commits: [ @@ -1515,19 +1396,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -1605,7 +1476,6 @@ RefInfo { ), segments: [ 👉ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "►lane", remote_tracking_ref_name: "None", commits: [ @@ -1618,7 +1488,6 @@ RefInfo { base: "None", }, ref_info::ui::Segment { - id: NodeIndex(7), ref_name: "►lane-segment-01", remote_tracking_ref_name: "None", commits: [], @@ -1629,7 +1498,6 @@ RefInfo { base: "None", }, ref_info::ui::Segment { - id: NodeIndex(8), ref_name: "►lane-segment-02", remote_tracking_ref_name: "None", commits: [], @@ -1650,7 +1518,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "►lane-2", remote_tracking_ref_name: "None", commits: [], @@ -1661,7 +1528,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 +1538,6 @@ RefInfo { base: "None", }, ref_info::ui::Segment { - id: NodeIndex(6), ref_name: "►lane-2-segment-02", remote_tracking_ref_name: "None", commits: [], @@ -1690,19 +1555,9 @@ 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( - NodeIndex(3), - ), is_managed_ref: true, is_managed_commit: false, ancestor_workspace_commit: None, @@ -1763,7 +1618,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "►lane-2", remote_tracking_ref_name: "None", commits: [], @@ -1774,7 +1628,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 +1638,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 +1658,6 @@ RefInfo { ), segments: [ 👉ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "►lane", remote_tracking_ref_name: "None", commits: [ @@ -1819,7 +1670,6 @@ RefInfo { base: "None", }, ref_info::ui::Segment { - id: NodeIndex(7), ref_name: "►lane-segment-01", remote_tracking_ref_name: "None", commits: [], @@ -1830,7 +1680,6 @@ RefInfo { base: "None", }, ref_info::ui::Segment { - id: NodeIndex(8), ref_name: "►lane-segment-02", remote_tracking_ref_name: "None", commits: [], @@ -1848,19 +1697,9 @@ 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( - NodeIndex(3), - ), is_managed_ref: true, is_managed_commit: false, ancestor_workspace_commit: None, @@ -1920,19 +1759,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 { - id: NodeIndex(3), - 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, @@ -1943,17 +1779,18 @@ RefInfo { }, Stack { id: Some( - 00000000-0000-0000-0000-000000000001, + 00000000-0000-0000-0000-000000000000, ), base: Some( Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), ), segments: [ ref_info::ui::Segment { - id: NodeIndex(4), - 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, @@ -1968,19 +1805,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -2045,7 +1872,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,19 +1891,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -2150,7 +1966,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(5), ref_name: "►dependent", remote_tracking_ref_name: "None", commits: [], @@ -2161,7 +1976,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,19 +1995,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -2249,7 +2053,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 +2063,6 @@ RefInfo { base: "cbc6713", }, ref_info::ui::Segment { - id: NodeIndex(6), ref_name: "►dependent", remote_tracking_ref_name: "None", commits: [ @@ -2280,19 +2082,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -2362,7 +2154,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(5), ref_name: "►on-top-of-dependent", remote_tracking_ref_name: "None", commits: [], @@ -2373,7 +2164,6 @@ RefInfo { base: "None", }, ref_info::ui::Segment { - id: NodeIndex(6), ref_name: "►dependent", remote_tracking_ref_name: "None", commits: [], @@ -2384,7 +2174,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,19 +2193,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -2470,7 +2249,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(5), ref_name: "►dependent", remote_tracking_ref_name: "None", commits: [], @@ -2481,7 +2259,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 +2269,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,19 +2288,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -2593,7 +2359,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 +2371,6 @@ RefInfo { base: "cbc6713", }, ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "►lane", remote_tracking_ref_name: "refs/remotes/origin/lane", commits: [ @@ -2626,19 +2390,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -2707,7 +2461,6 @@ RefInfo { ), segments: [ 👉ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "►A", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -2721,7 +2474,6 @@ RefInfo { base: "f15ca75", }, ref_info::ui::Segment { - id: NodeIndex(5), ref_name: "►integrated", remote_tracking_ref_name: "None", commits: [ @@ -2742,19 +2494,9 @@ 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( - NodeIndex(3), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -2774,10 +2516,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() { @@ -2795,7 +2541,7 @@ RefInfo { "refs/heads/gitbutler/workspace", ), commit_id: Some( - Sha1(cbc6713ccfc78aa9a3c9cf8305a6fadce0bbe1a4), + Sha1(335d6f2a960f387b039bd77476ae3d2d6649ed70), ), worktree: Some( Worktree { @@ -2811,19 +2557,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 { - id: NodeIndex(3), - 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, @@ -2834,17 +2577,18 @@ RefInfo { }, Stack { id: Some( - 00000000-0000-0000-0000-000000000001, + 00000000-0000-0000-0000-000000000000, ), base: Some( Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), ), segments: [ ref_info::ui::Segment { - id: NodeIndex(4), - 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, @@ -2859,21 +2603,11 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, - is_managed_commit: false, + is_managed_commit: true, ancestor_workspace_commit: None, is_entrypoint: true, } @@ -2898,7 +2632,7 @@ RefInfo { "refs/heads/gitbutler/workspace", ), commit_id: Some( - Sha1(cbc6713ccfc78aa9a3c9cf8305a6fadce0bbe1a4), + Sha1(335d6f2a960f387b039bd77476ae3d2d6649ed70), ), worktree: Some( Worktree { @@ -2914,19 +2648,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 { - id: NodeIndex(0), - 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, @@ -2937,17 +2668,18 @@ RefInfo { }, Stack { id: Some( - 00000000-0000-0000-0000-000000000001, + 00000000-0000-0000-0000-000000000000, ), base: Some( Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), ), segments: [ - ref_info::ui::Segment { - id: NodeIndex(4), - 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, @@ -2962,21 +2694,11 @@ 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( - NodeIndex(3), - ), is_managed_ref: true, - is_managed_commit: false, + is_managed_commit: true, ancestor_workspace_commit: None, is_entrypoint: false, } @@ -3036,17 +2758,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 { - id: NodeIndex(4), - 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, @@ -3057,19 +2780,16 @@ RefInfo { }, Stack { id: Some( - 00000000-0000-0000-0000-000000000001, + 00000000-0000-0000-0000-000000000000, ), base: Some( Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), - 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, @@ -3084,19 +2804,9 @@ 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), + commits_ahead: 0, }, ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -3137,17 +2847,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 { - id: NodeIndex(4), - 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, @@ -3158,19 +2869,16 @@ RefInfo { }, Stack { id: Some( - 00000000-0000-0000-0000-000000000001, + 00000000-0000-0000-0000-000000000000, ), base: Some( Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), ), segments: [ - 👉ref_info::ui::Segment { - id: NodeIndex(0), - 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, @@ -3185,19 +2893,9 @@ 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), + commits_ahead: 0, }, ), - lower_bound: Some( - NodeIndex(3), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -3233,17 +2931,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 { - id: NodeIndex(4), - 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, @@ -3254,19 +2953,16 @@ RefInfo { }, Stack { id: Some( - 00000000-0000-0000-0000-000000000001, + 00000000-0000-0000-0000-000000000000, ), base: Some( Sha1(fafd9d08a839d99db60b222cd58e2e0bfaf1f7b2), ), segments: [ - ref_info::ui::Segment { - id: NodeIndex(3), - 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, @@ -3281,19 +2977,9 @@ 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), + commits_ahead: 0, }, ), - lower_bound: Some( - NodeIndex(0), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -3342,7 +3028,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "►advanced-lane", remote_tracking_ref_name: "None", commits: [ @@ -3365,7 +3050,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "►lane", remote_tracking_ref_name: "None", commits: [], @@ -3383,19 +3067,9 @@ 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), + commits_ahead: 0, }, ), - lower_bound: Some( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -3455,7 +3129,6 @@ RefInfo { base: None, segments: [ ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "►disjoint[🌳]", remote_tracking_ref_name: "None", commits: [], @@ -3473,19 +3146,9 @@ 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( - NodeIndex(0), - ), is_managed_ref: false, is_managed_commit: false, ancestor_workspace_commit: None, @@ -3503,10 +3166,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 |/ @@ -3532,7 +3195,7 @@ RefInfo { "refs/heads/gitbutler/workspace", ), commit_id: Some( - Sha1(820f2b3c5007e15ba4558556a81d241fcee06856), + Sha1(fe1a116f4ca988026773b0739234a1baaab5649d), ), worktree: Some( Worktree { @@ -3547,28 +3210,24 @@ RefInfo { }, stacks: [ Stack { - id: Some( - 00000000-0000-0000-0000-000000000001, - ), + id: None, base: Some( Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), - 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", }, ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "►A", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -3585,26 +3244,26 @@ RefInfo { ], }, Stack { - id: None, + id: Some( + 00000000-0000-0000-0000-000000000001, + ), base: Some( Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), ), segments: [ ref_info::ui::Segment { - id: NodeIndex(6), - 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", }, ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "►A", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -3626,19 +3285,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -3662,7 +3311,7 @@ RefInfo { "refs/heads/gitbutler/workspace", ), commit_id: Some( - Sha1(820f2b3c5007e15ba4558556a81d241fcee06856), + Sha1(fe1a116f4ca988026773b0739234a1baaab5649d), ), worktree: Some( Worktree { @@ -3677,28 +3326,24 @@ RefInfo { }, stacks: [ Stack { - id: Some( - 00000000-0000-0000-0000-000000000001, - ), + id: None, base: Some( Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), ), segments: [ - 👉ref_info::ui::Segment { - id: NodeIndex(0), - 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", }, ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "►A", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -3715,26 +3360,26 @@ RefInfo { ], }, Stack { - id: None, + id: Some( + 00000000-0000-0000-0000-000000000001, + ), base: Some( Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), ), segments: [ - ref_info::ui::Segment { - id: NodeIndex(6), - 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", }, ref_info::ui::Segment { - id: NodeIndex(4), ref_name: "►A", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -3756,19 +3401,9 @@ 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( - NodeIndex(3), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -3792,7 +3427,7 @@ RefInfo { "refs/heads/gitbutler/workspace", ), commit_id: Some( - Sha1(820f2b3c5007e15ba4558556a81d241fcee06856), + Sha1(fe1a116f4ca988026773b0739234a1baaab5649d), ), worktree: Some( Worktree { @@ -3807,28 +3442,24 @@ RefInfo { }, stacks: [ Stack { - id: Some( - 00000000-0000-0000-0000-000000000001, - ), + id: None, base: Some( Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), ), segments: [ - ref_info::ui::Segment { - id: NodeIndex(4), - 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", }, ref_info::ui::Segment { - id: NodeIndex(5), ref_name: "►A", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -3845,26 +3476,26 @@ RefInfo { ], }, Stack { - id: None, + id: Some( + 00000000-0000-0000-0000-000000000001, + ), base: Some( Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), ), segments: [ - 👉ref_info::ui::Segment { - id: NodeIndex(0), - 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", }, ref_info::ui::Segment { - id: NodeIndex(5), ref_name: "►A", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -3886,19 +3517,9 @@ 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( - NodeIndex(3), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -3923,7 +3544,7 @@ RefInfo { "refs/heads/gitbutler/workspace", ), commit_id: Some( - Sha1(820f2b3c5007e15ba4558556a81d241fcee06856), + Sha1(fe1a116f4ca988026773b0739234a1baaab5649d), ), worktree: Some( Worktree { @@ -3938,28 +3559,24 @@ RefInfo { }, stacks: [ Stack { - id: Some( - 00000000-0000-0000-0000-000000000001, - ), + id: None, base: Some( Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), ), segments: [ ref_info::ui::Segment { - id: NodeIndex(4), - 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", }, 👉ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "►A", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -3976,26 +3593,26 @@ RefInfo { ], }, Stack { - id: None, + id: Some( + 00000000-0000-0000-0000-000000000001, + ), base: Some( Sha1(c166d42d4ef2e5e742d33554d03805cfb0b24d11), ), segments: [ ref_info::ui::Segment { - id: NodeIndex(6), - 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", }, 👉ref_info::ui::Segment { - id: NodeIndex(0), ref_name: "►A", remote_tracking_ref_name: "refs/remotes/origin/A", commits: [ @@ -4017,19 +3634,9 @@ 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( - NodeIndex(3), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -4091,7 +3698,6 @@ RefInfo { ), segments: [ ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "►unrelated", remote_tracking_ref_name: "None", commits: [], @@ -4109,19 +3715,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -4166,7 +3762,6 @@ RefInfo { ), segments: [ 👉ref_info::ui::Segment { - id: NodeIndex(3), ref_name: "►unrelated", remote_tracking_ref_name: "None", commits: [], @@ -4184,19 +3779,9 @@ 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( - NodeIndex(0), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -4240,19 +3825,9 @@ 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( - NodeIndex(2), - ), is_managed_ref: true, is_managed_commit: true, ancestor_workspace_commit: None, @@ -4272,58 +3847,35 @@ 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 { - id: NodeIndex(0), - 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( "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, - 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, } "#]] @@ -4400,7 +3952,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. @@ -4417,7 +3969,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}, @@ -4497,7 +4049,7 @@ pub(crate) mod utils { init_meta: impl FnMut(&mut VirtualBranchesTomlMetadata), ) -> anyhow::Result<( TempDir, - but_graph::Graph, + but_graph::Workspace, gix::Repository, VirtualBranchesTomlMetadata, String, @@ -4541,7 +4093,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, @@ -4557,7 +4109,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, @@ -4566,7 +4118,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 b807676b11a..a1f9524e57a 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,26 +250,25 @@ 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#" -📕🏘️: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 (🏘️) "#]] ); - 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,22 +431,21 @@ 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#" -⌂: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 (✓) "#]] ); - 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,22 +588,21 @@ 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#" -⌂: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 (✓) "#]] ); - 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,16 +791,15 @@ 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#" -📕🏘️: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 (🏘️) "#]] @@ -830,15 +821,14 @@ 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#" -📕🏘️: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 (🏘️) "#]] @@ -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,13 +880,12 @@ 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#" -📕🏘️: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 (🏘️|✓) "#]] @@ -912,13 +901,12 @@ 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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 905d6e5 +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 905d6e5 "#]] ); @@ -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,13 +959,12 @@ 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#" -📕🏘️: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 (🏘️|✓) @@ -994,13 +981,12 @@ 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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 6b20716 +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 6b20716 "#]] ); @@ -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,14 +1053,14 @@ 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#" -📕🏘️: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 (🏘️|✓) @@ -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,13 +1113,12 @@ 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#" -📕🏘️: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 (🏘️|✓) @@ -1152,13 +1135,12 @@ 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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on f27db86 +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on f27db86 "#]] ); @@ -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,16 +1437,15 @@ 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#" -📕🏘️: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 (🏘️) "#]] @@ -1483,18 +1460,17 @@ 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#" -📕🏘️: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 (🏘️) "#]] @@ -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,18 +1640,17 @@ 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#" -📕🏘️: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 (🏘️) "#]] @@ -1699,18 +1672,17 @@ 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#" -📕🏘️: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 (🏘️) "#]] @@ -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,18 +1744,17 @@ 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#" -📕🏘️: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 (🏘️) "#]] @@ -1805,15 +1776,14 @@ 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#" -📕🏘️: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 (🏘️) "#]] @@ -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,16 +1849,15 @@ 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#" -📕🏘️: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 (🏘️|✓) "#]] @@ -1909,20 +1879,19 @@ 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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 5f7d45e +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 5f7d45e "#]] ); 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,13 +2125,12 @@ 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#" -📕🏘️: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 (🏘️|✓) "#]] @@ -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,14 +2158,14 @@ 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#" -📕🏘️: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 (🏘️|✓) "#]] @@ -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,13 +2227,12 @@ 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#" -📕🏘️: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 (🏘️|✓) @@ -2298,16 +2261,16 @@ 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#" -📕🏘️: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 (🏘️|✓) "#]] @@ -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,14 +2331,13 @@ 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#" -📕🏘️: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 (🏘️|✓) "#]] @@ -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#" -📕🏘️:0:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 563a7fc -└── ≡📙:2:top <> origin/top →:4: on 563a7fc {1} - └── 📙:2:top <> origin/top →:4: - └── ·334227d (🏘️|✓) +📕🏘️:gitbutler/workspace[🌳] <> ✓refs/remotes/origin/main on 563a7fc +└── ≡📙:top <> origin/top on 563a7fc {1} + └── 📙:top <> origin/top + └── ·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,26 +2601,25 @@ 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#" -📕🏘️: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 (🏘️) "#]] ); - 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,14 +2633,14 @@ 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#" -📕🏘️: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 (🏘️) "#]] @@ -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,23 +2715,22 @@ 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#" -⌂: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 "#]] ); - 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,14 +2743,14 @@ 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#" -⌂: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 "#]] @@ -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,13 +2817,12 @@ 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#" -📕🏘️: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 (🏘️) @@ -2887,15 +2843,14 @@ 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#" -📕🏘️: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 (🏘️) "#]] @@ -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,13 +2914,12 @@ 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#" -⌂: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 @@ -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,13 +2960,12 @@ 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#" -⌂: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 "#]] @@ -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 a93aa5a02f2..3dcc7ab0bca 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/config.rs b/crates/but/src/command/config.rs index ca8193a6175..dbb2dd36140 100644 --- a/crates/but/src/command/config.rs +++ b/crates/but/src/command/config.rs @@ -1971,7 +1971,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!( @@ -1980,7 +1980,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/commit.rs b/crates/but/src/command/legacy/commit.rs index 08594b5e36e..d56e88da8bf 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 d4a39012beb..8d6bd123bf4 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 02d82927e39..bc3bbdc2a6c 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 dc4c49c6579..6911537e76b 100644 --- a/crates/but/src/command/legacy/rub/amend.rs +++ b/crates/but/src/command/legacy/rub/amend.rs @@ -61,8 +61,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 331c293fe6a..821cd795eb2 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 1be20dedd76..1a5712137ec 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 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/json.rs b/crates/but/src/command/legacy/status/json.rs index 9e8fa449295..c70f5dd3381 100644 --- a/crates/but/src/command/legacy/status/json.rs +++ b/crates/but/src/command/legacy/status/json.rs @@ -12,7 +12,6 @@ use std::collections::HashMap; use anyhow::Context as _; -use but_graph::SegmentIndex; use but_workspace::ref_info::LocalCommit; use chrono::{DateTime, Utc}; use serde::Serialize; @@ -312,7 +311,10 @@ impl Branch { repo: &gix::Repository, cli_id: String, segment: SegmentWithId, - push_statuses_by_segment_id: &HashMap, + 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 a28d283eb00..2620ddd5e5b 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); } } @@ -393,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, ) @@ -776,7 +798,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), @@ -1062,14 +1084,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/app/rub_mode.rs b/crates/but/src/command/legacy/status/tui/app/rub_mode.rs index dbe28a2de94..cfde6b47e94 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 @@ -342,8 +342,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/command/legacy/status/tui/operations.rs b/crates/but/src/command/legacy/status/tui/operations.rs index 4b22d7bbc87..3918f2be839 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..de02976fc7e 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,27 @@ B ] ┊● - l - rm + zq + r add B ├╯ - - 0dc3733 - (common - base) - 2000-01-02 - add - M + + + 7608322 + (upstream: + origin/main) + 1 + new + 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..c2ee1388a12 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,27 @@ ] ✔︎ - l - rm + zq + r add B ├╯ - - 0dc3733 - (common - base) - 2000-01-02 - add - M + + + 7608322 + (upstream: + origin/main) + 1 + new + 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..95ed36e0ed0 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,27 @@ << source >> - l - rm + zq + r add B ├╯ - - 0dc3733 - (common - base) - 2000-01-02 - add - M + + + 7608322 + (upstream: + origin/main) + 1 + new + 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..ea7d03b2612 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,27 @@ << noop >> - l - rm + zq + r add B ├╯ - - 0dc3733 - (common - base) - 2000-01-02 - add - M + + + 7608322 + (upstream: + origin/main) + 1 + new + 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..8fc6e7cb2c3 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,27 @@ B ] ┊● - l - rm + zq + r add B ├╯ - - 0dc3733 - (common - base) - 2000-01-02 - add - M + + + 7608322 + (upstream: + origin/main) + 1 + new + 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 faf726a2083..41746964c34 100644 --- a/crates/but/src/command/legacy/status/tui/tests/utils.rs +++ b/crates/but/src/command/legacy/status/tui/tests/utils.rs @@ -203,6 +203,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/mod.rs b/crates/but/src/id/mod.rs index e6e5ba810cb..b5e37c22adc 100644 --- a/crates/but/src/id/mod.rs +++ b/crates/but/src/id/mod.rs @@ -872,8 +872,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()) @@ -900,7 +900,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 f8067a1064e..9cc070fcddf 100644 --- a/crates/but/src/id/parser.rs +++ b/crates/but/src/id/parser.rs @@ -193,8 +193,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/id/tests.rs b/crates/but/src/id/tests.rs index 5ad99cd3a67..dcf15f9bfad 100644 --- a/crates/but/src/id/tests.rs +++ b/crates/but/src/id/tests.rs @@ -2828,21 +2828,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 56674e8fb88..6a20134f405 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..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 @@ -578,14 +579,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/src/utils/rejection.rs b/crates/but/src/utils/rejection.rs index 90096316317..1ce5de04c4c 100644 --- a/crates/but/src/utils/rejection.rs +++ b/crates/but/src/utils/rejection.rs @@ -351,12 +351,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), ); @@ -382,7 +382,10 @@ fn branches_touching_path( path: &bstr::BStr, ) -> Vec { let mut branches = Vec::new(); - for stack in &ws.stacks { + let Ok(stacks) = ws.display_stacks() else { + return branches; + }; + for stack in &stacks { for segment in &stack.segments { let touches = segment.commits.iter().any(|commit| { but_core::diff::tree_changes(repo, commit.parent_ids.first().copied(), commit.id) 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/new.rs b/crates/but/tests/but/command/branch/new.rs index 0a27dcd1d07..404a033e614 100644 --- a/crates/but/tests/but/command/branch/new.rs +++ b/crates/but/tests/but/command/branch/new.rs @@ -280,3 +280,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/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/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 b9958bf2308..43ff33105b4 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 f3cb5a4ec6c..3ec4832acb9 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 ├╯ @@ -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/move2.rs b/crates/but/tests/but/command/move2.rs index 648bc34e7e7..885a640dad8 100644 --- a/crates/but/tests/but/command/move2.rs +++ b/crates/but/tests/but/command/move2.rs @@ -1851,17 +1851,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 @@ -1912,17 +1912,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 @@ -2026,17 +2026,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/pull.rs b/crates/but/tests/but/command/pull.rs index edc037973b2..e43c67f6f48 100644 --- a/crates/but/tests/but/command/pull.rs +++ b/crates/but/tests/but/command/pull.rs @@ -725,6 +725,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/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/status.rs b/crates/but/tests/but/command/status.rs index 4906a094f7c..92d73279c0a 100644 --- a/crates/but/tests/but/command/status.rs +++ b/crates/but/tests/but/command/status.rs @@ -686,10 +686,8 @@ Applied remote branch 'origin/document-but-pr-skill' to workspace ┊╭┄do [document-but-pr-skill] (merged upstream) (no commits) ├╯ ┊ -┊● 55165db (upstream: origin/main) 1 new commit -├╯ 55165db (common base) 2000-01-02 merge document-but-pr-skill +┴ 55165db (common base) 2000-01-02 merge document-but-pr-skill -Hint: origin/main moved ahead; run `but pull` to update the workspace Hint: branches marked `(merged upstream)` have landed; run `but pull` to remove them, or start new work on another branch "#]]); 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/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 diff --git a/crates/gitbutler-branch-actions/src/branch.rs b/crates/gitbutler-branch-actions/src/branch.rs index 2cd37050c0f..cda69e275a1 100644 --- a/crates/gitbutler-branch-actions/src/branch.rs +++ b/crates/gitbutler-branch-actions/src/branch.rs @@ -77,7 +77,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/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/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-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 1279d0a85ad..ac4a51022f1 100644 --- a/crates/gitbutler-edit-mode/src/lib.rs +++ b/crates/gitbutler-edit-mode/src/lib.rs @@ -216,19 +216,22 @@ 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<()> { #[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(()) } @@ -313,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::>(); @@ -345,15 +348,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-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-oplog/src/oplog.rs b/crates/gitbutler-oplog/src/oplog.rs index 594ec851a6d..7eba8647f74 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/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, }); 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 9c20f82c955..43b62a9f088 100644 --- a/packages/but-sdk/src/generated/graph/index.d.ts +++ b/packages/but-sdk/src/generated/graph/index.d.ts @@ -1238,7 +1238,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; @@ -2584,7 +2584,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 46ce32ff1f9..79f809efd38 100644 --- a/packages/but-sdk/src/generated/linear/index.d.ts +++ b/packages/but-sdk/src/generated/linear/index.d.ts @@ -1238,7 +1238,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; @@ -2584,7 +2584,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.