From bde2189ce6c3542bf63f0864e0caf36a1c625e8c Mon Sep 17 00:00:00 2001 From: David Pedersen Date: Thu, 20 Aug 2026 12:14:36 +0200 Subject: [PATCH 1/2] feat(tui): show label while in single-branch-mode I've found this useful while developing because its not clear from the status itself if you're in SBM or not. When we actually roll out SBM this label can be removed. --- .../src/command/legacy/status/tui/app/mod.rs | 21 ++++++- .../src/command/legacy/status/tui/render.rs | 23 +++++++- .../command/legacy/status/tui/tests/mod.rs | 1 + .../status/tui/tests/single_branch_mode.rs | 15 +++++ ...ingle_branch_mode_label_in_hot_bar_001.svg | 56 +++++++++++++++++++ .../command/legacy/status/tui/tests/utils.rs | 4 ++ 6 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 crates/but/src/command/legacy/status/tui/tests/single_branch_mode.rs create mode 100644 crates/but/src/command/legacy/status/tui/tests/snapshots/shows_single_branch_mode_label_in_hot_bar_001.svg diff --git a/crates/but/src/command/legacy/status/tui/app/mod.rs b/crates/but/src/command/legacy/status/tui/app/mod.rs index c85878f172d..a234716efa7 100644 --- a/crates/but/src/command/legacy/status/tui/app/mod.rs +++ b/crates/but/src/command/legacy/status/tui/app/mod.rs @@ -136,6 +136,7 @@ pub struct App { pub head_sha: String, pub clipboard: Clipboard, pub operating_mode: OperatingMode, + pub is_in_single_branch_mode: bool, } pub(super) fn changed_paths_affect_uncommitted_details<'a>( @@ -402,7 +403,7 @@ impl App { let file_browser = show_file_browser.then(FileBrowser::default); - Ok(Self { + let mut app = Self { status_lines, flags, cursor, @@ -431,7 +432,12 @@ impl App { head_sha, clipboard, operating_mode, - }) + is_in_single_branch_mode: false, + }; + + app.reload_is_in_single_branch_mode(ctx)?; + + Ok(app) } pub fn active_key_binds(&self) -> &KeyBinds { @@ -1394,6 +1400,17 @@ impl App { } } + self.reload_is_in_single_branch_mode(ctx)?; + + Ok(()) + } + + fn reload_is_in_single_branch_mode(&mut self, ctx: &Context) -> anyhow::Result<()> { + let guard = ctx.shared_worktree_access(); + + self.is_in_single_branch_mode = ctx.settings.feature_flags.single_branch + && gitbutler_operating_modes::in_outside_workspace_mode(ctx, guard.read_permission())?; + Ok(()) } diff --git a/crates/but/src/command/legacy/status/tui/render.rs b/crates/but/src/command/legacy/status/tui/render.rs index ade1842cd65..7bef7bc6ff9 100644 --- a/crates/but/src/command/legacy/status/tui/render.rs +++ b/crates/but/src/command/legacy/status/tui/render.rs @@ -1031,9 +1031,26 @@ fn render_hot_bar(app: &App, area: Rect, frame: &mut Frame) { frame.render_widget(" ", layout[1]); - app.mode - .as_mode_render() - .render_hot_bar_content(app, layout[2], frame); + if app.is_in_single_branch_mode { + let content_layout = + Layout::horizontal([Constraint::Min(1), Constraint::Length(5)]).split(layout[2]); + + app.mode + .as_mode_render() + .render_hot_bar_content(app, content_layout[0], frame); + + frame.render_widget( + Span::styled( + " SBM ", + Style::default().bg(ModeDiscriminant::Normal.bg(app.theme)), + ), + content_layout[1], + ); + } else { + app.mode + .as_mode_render() + .render_hot_bar_content(app, layout[2], frame); + } if let Some(started_at) = loading_spinner_started_at { let mut line = RenderSingleLineSpans::new(frame, layout[3]); diff --git a/crates/but/src/command/legacy/status/tui/tests/mod.rs b/crates/but/src/command/legacy/status/tui/tests/mod.rs index 04b2760b8bb..efcb6257d4e 100644 --- a/crates/but/src/command/legacy/status/tui/tests/mod.rs +++ b/crates/but/src/command/legacy/status/tui/tests/mod.rs @@ -29,6 +29,7 @@ mod marking_tests; mod move_tests; mod open_tests; mod pick_tests; +mod single_branch_mode; mod squash_tests; mod stack_tests; mod utils; diff --git a/crates/but/src/command/legacy/status/tui/tests/single_branch_mode.rs b/crates/but/src/command/legacy/status/tui/tests/single_branch_mode.rs new file mode 100644 index 00000000000..7b359eb902a --- /dev/null +++ b/crates/but/src/command/legacy/status/tui/tests/single_branch_mode.rs @@ -0,0 +1,15 @@ +use but_testsupport::Sandbox; +use snapbox::file; + +use crate::command::legacy::status::tui::tests::utils::test_status_tui; + +#[test] +fn shows_single_branch_mode_label_in_hot_bar() { + let env = Sandbox::init_scenario_with_target_and_default_settings("single-branch-mode"); + + let mut tui = test_status_tui(env); + + tui.reload().assert_rendered_term_svg_eq(file![ + "snapshots/shows_single_branch_mode_label_in_hot_bar_001.svg" + ]); +} diff --git a/crates/but/src/command/legacy/status/tui/tests/snapshots/shows_single_branch_mode_label_in_hot_bar_001.svg b/crates/but/src/command/legacy/status/tui/tests/snapshots/shows_single_branch_mode_label_in_hot_bar_001.svg new file mode 100644 index 00000000000..45ce89339b6 --- /dev/null +++ b/crates/but/src/command/legacy/status/tui/tests/snapshots/shows_single_branch_mode_label_in_hot_bar_001.svg @@ -0,0 +1,56 @@ + + + + + + + ╭┄ + zz + [ + uncommitted + ] + (no + changes) + + ┊╭┄ + ma + [ + main + ] + (no + commits) + ├╯ + + + b1540e5 + (common + base) + 2000-01-02 + M + normal + ↑/k + up + + ↓/j + down + + c + commit + + r + squash + + m + move + + b + branch + + ? + help + + q + quit + SBM + + 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 554cb6846a1..6f8da7bb392 100644 --- a/crates/but/src/command/legacy/status/tui/tests/utils.rs +++ b/crates/but/src/command/legacy/status/tui/tests/utils.rs @@ -95,6 +95,10 @@ pub fn test_status_tui_with_options(mut env: Sandbox, options: TestTuiOptions) - StatusRenderMode::Tui(launch_options.clone()), ) .expect("failed to build status context"); + + // App::new acquires a new guard so have to drop this before calling that + drop(guard); + let initial_target = resolve_tui_target( &ctx.repo.get().unwrap(), &status_ctx.id_map, From da222a84ab08999cc6156661c5849c42365acf76 Mon Sep 17 00:00:00 2001 From: David Pedersen Date: Tue, 18 Aug 2026 13:29:06 +0200 Subject: [PATCH 2/2] feat(cli): creating a branch takes you to workspace mode --- crates/but-api/src/branch.rs | 1 + crates/but-debug/src/command/workspace.rs | 1 + crates/but-workspace/src/branch/apply.rs | 77 ++- .../src/branch/create_reference.rs | 56 +- .../tests/workspace/branch/apply_unapply.rs | 1 + crates/but/src/command/legacy/branch/new.rs | 373 ++++++++++---- crates/but/src/command/legacy/push.rs | 12 +- .../src/command/legacy/status/tui/app/mod.rs | 11 +- ...out_branch_in_single_branch_mode_final.svg | 15 +- .../marks_spanning_checkouts_are_refused.svg | 41 +- crates/but/src/error.rs | 9 +- crates/but/tests/but/command/branch/new.rs | 479 +++++++++++++++--- crates/but/tests/but/command/commit.rs | 2 +- crates/but/tests/but/command/pull.rs | 4 +- crates/but/tests/but/command/push.rs | 1 - .../fixtures/scenario/single-branch-mode.sh | 18 + 16 files changed, 848 insertions(+), 253 deletions(-) create mode 100644 crates/but/tests/fixtures/scenario/single-branch-mode.sh diff --git a/crates/but-api/src/branch.rs b/crates/but-api/src/branch.rs index 32dfb21d0e9..fd7c6bc43bc 100644 --- a/crates/but-api/src/branch.rs +++ b/crates/but-api/src/branch.rs @@ -843,6 +843,7 @@ pub fn apply_only_with_perm( workspace_reference_naming: WorkspaceReferenceNaming::default(), order: None, new_stack_id: None, + allow_applying_already_applied_branch_when_outside_workspace: false, }, )?; diff --git a/crates/but-debug/src/command/workspace.rs b/crates/but-debug/src/command/workspace.rs index fd646d85bae..99497fe2732 100644 --- a/crates/but-debug/src/command/workspace.rs +++ b/crates/but-debug/src/command/workspace.rs @@ -34,6 +34,7 @@ pub(crate) fn apply( workspace_reference_naming: WorkspaceReferenceNaming::Default, order: None, new_stack_id: None, + ..Default::default() }, )?; diff --git a/crates/but-workspace/src/branch/apply.rs b/crates/but-workspace/src/branch/apply.rs index 5d1de22edbb..5fc14571324 100644 --- a/crates/but-workspace/src/branch/apply.rs +++ b/crates/but-workspace/src/branch/apply.rs @@ -1,10 +1,34 @@ +use anyhow::{Context as _, bail}; use bstr::ByteSlice as _; +use but_core::{ + ObjectStorageExt, RefMetadata, RepositoryExt, extract_remote_name_and_short_name, ref_metadata, + ref_metadata::{ + Workspace, + WorkspaceCommitRelation::{Merged, Outside}, + }, +}; use but_core::{ WORKSPACE_REF_NAME, ref_metadata::{StackId, StackKind}, }; +use but_graph::{SegmentIndex, init::Overlay, petgraph::Direction, workspace::WorkspaceKind}; +use gix::{ + prelude::ObjectIdExt, + reference::Category, + refs::{ + FullNameRef, Target, + transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}, + }, +}; +use tracing::instrument; use crate::branch::{OnWorkspaceMergeConflict, try_find_validated_ref}; +use crate::{ + WorkspaceCommit, + branch::{anon_stacks, ensure_no_missing_stacks}, + commit::merge::Tip, + ref_info::WorkspaceExt, +}; /// A stack that conflicted while applying a branch. #[derive(Clone)] @@ -167,34 +191,20 @@ pub struct Options { pub order: Option, /// Create new stack id, which by default is a function that generates a new StackId. pub new_stack_id: Option StackId>, + /// By default applying branches that are already applied is considered an error. Setting this + /// to `true` changes that so if we're not in a workspace, applying already applied branches is + /// allowed. + /// + /// The apply follows the normal apply path, as if the branch wasn't applied. + /// + /// The use case for this is to go from single branch mode to workspace mode. If we're in SBM + /// with branch `foo` applied and we apply `foo` with + /// `allow_applying_already_applied_branch_when_outside_workspace` set to `true`, we'll be put + /// into a workspace with only `foo` applied, regardless which branches were previously + /// applied. + pub allow_applying_already_applied_branch_when_outside_workspace: bool, } -use anyhow::{Context as _, bail}; -use but_core::{ - ObjectStorageExt, RefMetadata, RepositoryExt, extract_remote_name_and_short_name, ref_metadata, - ref_metadata::{ - Workspace, - WorkspaceCommitRelation::{Merged, Outside}, - }, -}; -use but_graph::{SegmentIndex, init::Overlay, petgraph::Direction, workspace::WorkspaceKind}; -use gix::{ - prelude::ObjectIdExt, - reference::Category, - refs::{ - FullNameRef, Target, - transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}, - }, -}; -use tracing::instrument; - -use crate::{ - WorkspaceCommit, - branch::{anon_stacks, ensure_no_missing_stacks}, - commit::merge::Tip, - ref_info::WorkspaceExt, -}; - /// Apply `branch` to the given `workspace`, and possibly create the workspace reference in `repo` /// along with its `meta`-data if it doesn't exist yet. /// The changed workspace will be checked out. @@ -228,6 +238,7 @@ pub fn apply( workspace_reference_naming, order, new_stack_id, + allow_applying_already_applied_branch_when_outside_workspace, }: Options, ) -> anyhow::Result { let ws = workspace; @@ -256,11 +267,22 @@ pub fn apply( branch = upstream_branch_name; branch_ref = try_find_validated_ref(repo, branch.as_ref(), "apply")?; } + let head_ref_name = repo.head_name()?.map(|name| name.to_owned()); + let head_on_managed_workspace_ref = match &ws.kind { + WorkspaceKind::Managed { ref_info } + | WorkspaceKind::ManagedMissingWorkspaceCommit { ref_info } => head_ref_name + .as_ref() + .is_some_and(|head| head.as_ref() == ref_info.ref_name.as_ref()), + WorkspaceKind::AdHoc => false, + }; 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 { + if branch_already_applied + && (!allow_applying_already_applied_branch_when_outside_workspace + || head_on_managed_workspace_ref) + { let workspace_ref_created = false; // When exiting early, don't try to adjust the ws commit. return Ok(Outcome { @@ -382,7 +404,6 @@ pub fn apply( }; // Whether HEAD already points at the workspace ref, or sits directly on a branch. When it's on a // branch we move HEAD onto the workspace ref and rebuild the workspace around the branches we keep. - let head_ref_name = repo.head_name()?.map(|rn| rn.to_owned()); let head_on_workspace_ref = head_ref_name .as_ref() .is_some_and(|head| head.as_bstr() == workspace_ref_name_to_update.as_bstr()); diff --git a/crates/but-workspace/src/branch/create_reference.rs b/crates/but-workspace/src/branch/create_reference.rs index cb9f3bd4a55..51294a897c5 100644 --- a/crates/but-workspace/src/branch/create_reference.rs +++ b/crates/but-workspace/src/branch/create_reference.rs @@ -330,30 +330,29 @@ pub(super) mod function { AnchorResolution::positioned(ref_target_id, validate_id, instruction) } - Some(Anchor::AtSegment { ref_name, position }) => { + Some(Anchor::AtSegment { + ref_name: anchor_ref, + 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())?; + workspace.try_find_segment_owner_indexes_by_refname(anchor_ref.as_ref())?; let segment = &workspace.stacks[stack_idx].segments[seg_idx]; - let id = workspace + 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", - )??; - if Some(id) == ws_base { - validate_id = false - } - id + )?? } else { let Some((_stack, segment)) = - workspace.find_segment_and_stack_by_refname(ref_name.as_ref()) + workspace.find_segment_and_stack_by_refname(anchor_ref.as_ref()) else { bail!( "Could not find a segment named '{}' in workspace", - ref_name.shorten() + anchor_ref.shorten() ); }; position.resolve_commit( @@ -365,11 +364,42 @@ pub(super) mod function { ws_base, )? }; - AnchorResolution::positioned( + let points_to_workspace_base = Some(ref_target_id) == ws_base; + if points_to_workspace_base { + validate_id = false; + } + // The lower bound owns no commits, so an ad-hoc workspace needs explicit ref + // ordering to project the new empty segment at that boundary. + let branch_stack_order = if !workspace.has_metadata() && points_to_workspace_base { + if !meta.can_persist_branch_stack_order() { + bail_precondition!( + "Cannot position '{new}' relative to local reference '{anchor}' at the workspace base without branch order metadata", + new = ref_name.shorten(), + anchor = anchor_ref.shorten() + ); + } + let existing_order = meta + .branch_stack_order(anchor_ref.as_ref())? + .unwrap_or_default(); + Some(insert_into_branch_stack_order( + existing_order, + anchor_ref.as_ref(), + ref_name, + position, + )) + } else { + None + }; + let mut resolution = AnchorResolution::positioned( ref_target_id, validate_id, - Some(Instruction::Dependent { ref_name, position }), - ) + Some(Instruction::Dependent { + ref_name: anchor_ref, + position, + }), + ); + resolution.branch_stack_order = branch_stack_order; + resolution } // Position relative to another *reference* on the same commit. Managed workspaces // order these in workspace metadata; ad-hoc workspaces record the order in the diff --git a/crates/but-workspace/tests/workspace/branch/apply_unapply.rs b/crates/but-workspace/tests/workspace/branch/apply_unapply.rs index 4a6d6ef35e8..10a999ef0f4 100644 --- a/crates/but-workspace/tests/workspace/branch/apply_unapply.rs +++ b/crates/but-workspace/tests/workspace/branch/apply_unapply.rs @@ -6226,6 +6226,7 @@ fn apply_options() -> but_workspace::branch::apply::Options { workspace_reference_naming: WorkspaceReferenceNaming::Default, order: None, new_stack_id: Some(stack_id_for_name), + allow_applying_already_applied_branch_when_outside_workspace: false, } } diff --git a/crates/but/src/command/legacy/branch/new.rs b/crates/but/src/command/legacy/branch/new.rs index 108eef8e618..1d64ca6aaa6 100644 --- a/crates/but/src/command/legacy/branch/new.rs +++ b/crates/but/src/command/legacy/branch/new.rs @@ -1,9 +1,9 @@ use std::borrow::Cow; -use anyhow::Context as _; +use anyhow::{Context as _, bail}; use but_core::{ DryRun, RefMetadata, - ref_metadata::StackId, + ref_metadata::{ProjectMeta, StackId}, sync::{RepoExclusive, RepoShared}, }; use but_ctx::Context; @@ -36,7 +36,6 @@ pub fn new( args: NewPlatform, ) -> CliResult { let mut guard = ctx.exclusive_worktree_access(); - let mut meta = ctx.meta()?; let id_map = IdMap::new_from_context(ctx, guard.read_permission())?; let operation = { @@ -44,6 +43,7 @@ pub fn new( resolve(ctx, guard.read_permission(), args, &head_info, &id_map)? }; + let mut meta = ctx.meta()?; Ok(run(ctx, &mut meta, guard.write_permission(), operation)?) } @@ -85,7 +85,9 @@ fn resolve( }; match (above, below) { - (None, None) => Ok(NewOperation::NewUnstackedBranch { name }), + (None, None) => Ok(NewOperation::NewUnstackedBranch( + NewUnstackedBranchOperation { name }, + )), (None, Some(target_below)) => { let target = resolve_above_below_target(&repo, id_map, target_below)?; @@ -98,19 +100,19 @@ fn resolve( } } - Ok(NewOperation::NewStackedBranch { + Ok(NewOperation::NewStackedBranch(NewStackedBranchOperation { name, target, side: Side::Below, - }) + })) } (Some(target_above), None) => { let target = resolve_above_below_target(&repo, id_map, target_above)?; - Ok(NewOperation::NewStackedBranch { + Ok(NewOperation::NewStackedBranch(NewStackedBranchOperation { name, target, side: Side::Above, - }) + })) } (Some(_), Some(_)) => { unreachable!("--above and --below are mutually exclusive in the clap args") @@ -141,14 +143,18 @@ fn resolve_above_below_target( } pub enum NewOperation { - NewUnstackedBranch { - name: Option, - }, - NewStackedBranch { - name: Option, - target: NewStackedBranchTarget, - side: Side, - }, + NewUnstackedBranch(NewUnstackedBranchOperation), + NewStackedBranch(NewStackedBranchOperation), +} + +pub struct NewUnstackedBranchOperation { + pub name: Option, +} + +pub struct NewStackedBranchOperation { + pub name: Option, + pub target: NewStackedBranchTarget, + pub side: Side, } pub enum NewStackedBranchTarget { @@ -162,89 +168,270 @@ pub fn run( perm: &mut RepoExclusive, operation: NewOperation, ) -> anyhow::Result { - let in_single_branch_mode = ctx.settings.feature_flags.single_branch - && gitbutler_operating_modes::in_outside_workspace_mode(ctx, perm.read_permission())?; - let mut checkout_after_create = false; - - let snapshot_details = SnapshotDetails::new(OperationKind::CreateBranch); - let (name, _ws) = but_transaction::with_transaction_with_perm( - ctx, - meta, - perm, - snapshot_details, - DryRun::No, - |mut tx| { - let new_ref = match &operation { - NewOperation::NewStackedBranch { name, .. } - | NewOperation::NewUnstackedBranch { name } => { - if let Some(name) = name { - name.clone() - } else { - but_core::branch::unique_canned_refname(tx.repo())? - } + match operation { + NewOperation::NewUnstackedBranch(op) => op.execute(ctx, meta, perm), + NewOperation::NewStackedBranch(op) => op.execute(ctx, meta, perm), + } +} + +impl NewUnstackedBranchOperation { + fn execute( + self, + ctx: &mut Context, + meta: &mut impl RefMetadata, + perm: &mut RepoExclusive, + ) -> anyhow::Result { + let in_single_branch_mode = ctx.settings.feature_flags.single_branch + && gitbutler_operating_modes::in_outside_workspace_mode(ctx, perm.read_permission())?; + + if in_single_branch_mode { + self.execute_single_branch_mode(ctx, meta, perm) + } else { + self.execute_workspace_mode(ctx, meta, perm) + } + } + + fn execute_workspace_mode( + self, + ctx: &mut Context, + meta: &mut impl RefMetadata, + perm: &mut RepoExclusive, + ) -> anyhow::Result { + let Self { name } = self; + + let snapshot_details = SnapshotDetails::new(OperationKind::CreateBranch); + + let (new_ref, _ws) = but_transaction::with_transaction_with_perm( + ctx, + meta, + perm, + snapshot_details, + DryRun::No, + |mut tx| { + let new_ref = if let Some(name) = name { + name.clone() + } else { + but_core::branch::unique_canned_refname(tx.repo())? + }; + + tx.create_reference(new_ref.as_ref(), None, |_| StackId::generate(), Some(0))?; + + Ok(but_transaction::Commit(new_ref)) + }, + )?; + + Ok(NewOutcome { + name: new_ref, + target: None, + }) + } + + fn execute_single_branch_mode( + self, + ctx: &mut Context, + meta: &mut impl RefMetadata, + perm: &mut RepoExclusive, + ) -> anyhow::Result { + let Self { name } = self; + + let snapshot_details = SnapshotDetails::new(OperationKind::CreateBranch); + + let repo = ctx.repo.get()?; + let project_meta = ProjectMeta::resolve(&repo)?; + let head_name = head_name(&repo)?; + + let new_ref = if let Some(name) = name { + name.clone() + } else { + but_core::branch::unique_canned_refname(&repo)? + }; + + let target_ref = project_meta + .target_ref + .as_ref() + .context("BUG: target ref is missing")?; + + let is_on_target = + but_core::branch::resolve_tracking_branch_ref_name(head_name.as_ref(), &repo) + .is_ok_and(|upstream| &*upstream == target_ref.as_ref()); + + if is_on_target { + // we're directly on the target then we haven't created any branches yet so + // create the branch on top of the target then check it out + + drop(repo); + + but_transaction::with_transaction_with_perm( + ctx, + meta, + perm, + snapshot_details, + DryRun::No, + |mut tx| { + let anchor = Some(Anchor::AtReference { + ref_name: Cow::Owned(head_name), + position: Side::Above.into(), + }); + + tx.create_reference( + new_ref.as_ref(), + anchor, + |_| StackId::generate(), + Some(0), + )?; + + Ok(()) + }, + )?; + + but_api::branch::branch_checkout_with_perm(ctx, new_ref.clone(), perm)?; + } else { + // if we're not on the target then enter a workspace and create the branch + + if repo + .try_find_reference(but_core::WORKSPACE_REF_NAME)? + .is_none() + { + // the workspace doesn't exist, create it + drop(repo); + let target_ref = target_ref.to_string().parse()?; + gitbutler_branch_actions::set_base_branch(ctx, &target_ref, perm)?; + } else { + drop(repo); + } + + // make sure the previous branch is applied + // if the branch had no commits `set_base_branch` doesn't apply it + // + // this also has the effect of entering the workspace with one branch applied + { + let (repo, mut ws, _db) = ctx.workspace_mut_and_db_with_perm(perm)?; + let outcome = but_workspace::branch::apply( + head_name.as_ref(), + ws.clone(), + &repo, + meta, + but_workspace::branch::apply::Options { + allow_applying_already_applied_branch_when_outside_workspace: true, + ..Default::default() + }, + )?; + if outcome.status.persisted_mutation() { + *ws = outcome.workspace.clone(); + } else { + bail!( + "BUG: failed to apply head ref ({head_name}). Failed with {:?}", + outcome.status + ) } }; - let anchor = match &operation { - NewOperation::NewUnstackedBranch { name: _ } => None, - NewOperation::NewStackedBranch { - name: _, - target, - side, - } => Some(match target { + but_transaction::with_transaction_with_perm( + ctx, + meta, + perm, + snapshot_details, + DryRun::No, + |mut tx| { + tx.create_reference(new_ref.as_ref(), None, |_| StackId::generate(), Some(0))?; + + Ok(()) + }, + )?; + } + + Ok(NewOutcome { + name: new_ref, + target: None, + }) + } +} + +impl NewStackedBranchOperation { + fn execute( + self, + ctx: &mut Context, + meta: &mut impl RefMetadata, + perm: &mut RepoExclusive, + ) -> anyhow::Result { + let Self { name, target, side } = self; + + let in_single_branch_mode = ctx.settings.feature_flags.single_branch + && gitbutler_operating_modes::in_outside_workspace_mode(ctx, perm.read_permission())?; + + let mut checkout_after_create = false; + + let snapshot_details = SnapshotDetails::new(OperationKind::CreateBranch); + + let (new_ref, _ws) = but_transaction::with_transaction_with_perm( + ctx, + meta, + perm, + snapshot_details, + DryRun::No, + |mut tx| { + let new_ref = if let Some(name) = name { + name.clone() + } else { + but_core::branch::unique_canned_refname(tx.repo())? + }; + + let anchor = match &target { NewStackedBranchTarget::Commit(commit_target) => Anchor::AtCommit { commit_id: commit_target.commit_id, - position: (*side).into(), + position: side.into(), }, NewStackedBranchTarget::Branch(branch_target) => { - Anchor::at_segment(branch_target.as_ref(), (*side).into()) + Anchor::at_segment(branch_target.as_ref(), side.into()) } - }), - }; - - let anchor = if let Some(anchor) = anchor { - match anchor { - Anchor::AtSegment { position, ref_name } - if matches!(position, Position::Above) && in_single_branch_mode => - { - let head_name = head_name(tx.repo())?; - if &*ref_name == head_name.as_ref() { - Some(single_branch_mode_anchor( - head_name, - &mut checkout_after_create, - )?) - } else { - Some(Anchor::AtReference { ref_name, position }) + }; + + let anchor = if in_single_branch_mode + && let Anchor::AtSegment { + position: position @ Position::Above, + ref_name, + } = anchor + { + // creating a new branch above HEAD works differently in single branch mode + // have to use a different anchor type and manually checkout the newly created + // branch + let head_name = head_name(tx.repo())?; + if &*ref_name == head_name.as_ref() { + checkout_after_create = true; + Anchor::AtReference { + ref_name: Cow::Owned(head_name), + position: Side::Above.into(), } + } else { + Anchor::AtReference { ref_name, position } } - _ => Some(anchor), - } - } else if in_single_branch_mode { - let head_name = head_name(tx.repo())?; - Some(single_branch_mode_anchor( - head_name, - &mut checkout_after_create, - )?) - } else { - None - }; - - tx.create_reference(new_ref.as_ref(), anchor, |_| StackId::generate(), Some(0))?; - - Ok(but_transaction::Commit(new_ref)) - }, - )?; + } else { + anchor + }; + + tx.create_reference( + new_ref.as_ref(), + anchor.clone(), + |_| StackId::generate(), + Some(0), + ) + .with_context(|| { + format!("failed to create reference. anchor={anchor:?}; new_ref={new_ref:?}") + })?; + + Ok(but_transaction::Commit(new_ref)) + }, + )?; + + if checkout_after_create { + but_api::branch::branch_checkout_with_perm(ctx, new_ref.clone(), perm)?; + } - if checkout_after_create { - but_api::branch::branch_checkout_with_perm(ctx, name.clone(), perm)?; + Ok(NewOutcome { + name: new_ref, + target: Some((target, side)), + }) } - - let target = match operation { - NewOperation::NewUnstackedBranch { .. } => None, - NewOperation::NewStackedBranch { target, side, .. } => Some((target, side)), - }; - - Ok(NewOutcome { name, target }) } fn head_name(repo: &gix::Repository) -> anyhow::Result { @@ -256,18 +443,6 @@ fn head_name(repo: &gix::Repository) -> anyhow::Result { .to_owned()) } -fn single_branch_mode_anchor( - head_name: FullName, - checkout_after_create: &mut bool, -) -> anyhow::Result> { - *checkout_after_create = true; - - Ok(Anchor::AtReference { - ref_name: Cow::Owned(head_name), - position: Side::Above.into(), - }) -} - #[must_use] pub struct NewOutcome { pub name: FullName, diff --git a/crates/but/src/command/legacy/push.rs b/crates/but/src/command/legacy/push.rs index 56cfade77f9..ab7265ce12c 100644 --- a/crates/but/src/command/legacy/push.rs +++ b/crates/but/src/command/legacy/push.rs @@ -7,8 +7,8 @@ use gitbutler_git::PushResult; use serde::Serialize; use crate::{ - CliId, CliResultExt as _, IdMap, - args::{push, push::Command}, + CliId, CliResult, IdMap, + args::push::{self, Command}, command::legacy::workspace_target, theme::{self, Paint}, utils::{ @@ -47,7 +47,7 @@ pub async fn handle( args: push::Command, ctx: &mut Context, out: &mut OutputChannel, -) -> anyhow::Result<()> { +) -> CliResult<()> { // Check gerrit mode early let gerrit_mode = { let repo = ctx.repo.get()?; @@ -56,7 +56,7 @@ pub async fn handle( // If dry-run, show what would be pushed if args.dry_run { - return handle_dry_run(ctx, &args.branch_id, out); + return Ok(handle_dry_run(ctx, &args.branch_id, out)?); } let id_map = { @@ -95,9 +95,7 @@ pub async fn handle( let merged = MergedUpstream::from_ctx(ctx, args.allow_merged)?; for name in names { let full_name = gix::refs::FullName::try_from(format!("refs/heads/{name}"))?; - merged - .ensure_branch_not_merged(full_name.as_ref()) - .into_internal_error()?; + merged.ensure_branch_not_merged(full_name.as_ref())?; } } diff --git a/crates/but/src/command/legacy/status/tui/app/mod.rs b/crates/but/src/command/legacy/status/tui/app/mod.rs index a234716efa7..62811e68924 100644 --- a/crates/but/src/command/legacy/status/tui/app/mod.rs +++ b/crates/but/src/command/legacy/status/tui/app/mod.rs @@ -22,7 +22,10 @@ use crate::{ legacy::{ branch::{ self, - new::{NewOperation, NewStackedBranchTarget}, + new::{ + NewOperation, NewStackedBranchOperation, NewStackedBranchTarget, + NewUnstackedBranchOperation, + }, }, commit::{ self, CommitAtOperation, CommitOperation, CommitRelativeToTarget, CommitSelection, @@ -1573,13 +1576,13 @@ impl App { ctx, &mut meta, guard.write_permission(), - NewOperation::NewStackedBranch { + NewOperation::NewStackedBranch(NewStackedBranchOperation { name: None, target: NewStackedBranchTarget::Branch( Category::LocalBranch.to_full_name(&*branch.name)?, ), side: Side::Above, - }, + }), )?; outcome.name.shorten().to_string() @@ -1594,7 +1597,7 @@ impl App { ctx, &mut meta, guard.write_permission(), - NewOperation::NewUnstackedBranch { name: None }, + NewOperation::NewUnstackedBranch(NewUnstackedBranchOperation { name: None }), )?; outcome.name.shorten().to_string() diff --git a/crates/but/src/command/legacy/status/tui/tests/snapshots/commit_from_unstaged_changes_to_new_branch_checks_out_branch_in_single_branch_mode_final.svg b/crates/but/src/command/legacy/status/tui/tests/snapshots/commit_from_unstaged_changes_to_new_branch_checks_out_branch_in_single_branch_mode_final.svg index 1b98943cf98..4c6145d925c 100644 --- a/crates/but/src/command/legacy/status/tui/tests/snapshots/commit_from_unstaged_changes_to_new_branch_checks_out_branch_in_single_branch_mode_final.svg +++ b/crates/but/src/command/legacy/status/tui/tests/snapshots/commit_from_unstaged_changes_to_new_branch_checks_out_branch_in_single_branch_mode_final.svg @@ -2,6 +2,7 @@ + ╭┄ zz @@ -61,13 +62,11 @@ b branch - s - stack - - ? - help - - q - quit + ? + help + + q + quit + SBM diff --git a/crates/but/src/command/legacy/status/tui/tests/snapshots/marks_spanning_checkouts_are_refused.svg b/crates/but/src/command/legacy/status/tui/tests/snapshots/marks_spanning_checkouts_are_refused.svg index c1af737edd4..2957703df47 100644 --- a/crates/but/src/command/legacy/status/tui/tests/snapshots/marks_spanning_checkouts_are_refused.svg +++ b/crates/but/src/command/legacy/status/tui/tests/snapshots/marks_spanning_checkouts_are_refused.svg @@ -48,36 +48,19 @@ 2000-01-02 add M - ┌───────────────────────────────────────────────────────────────────────┐ - - Cannot - use - changes - from - the - uncommitted - area - and - worktree - wt - together - - - + ┌───────────────────────────────────────────────────────────────────────┐ - Hint: - An - operation - can - only - take - changes - from - one - checkout - at - a - time + Cannot + use + changes + from + the + uncommitted + area + and + worktree + wt + together └───────────────────────────────────────────────────────────────────────┘ normal diff --git a/crates/but/src/error.rs b/crates/but/src/error.rs index e683277101e..a8f36d6f8a8 100644 --- a/crates/but/src/error.rs +++ b/crates/but/src/error.rs @@ -172,7 +172,14 @@ impl CliError { pub fn into_internal(self) -> anyhow::Error { match self { - Self::BadInput(..) | Self::ExternalCommandNotFound(..) | Self::CommandRejection => { + Self::BadInput(mut err) => { + // the hints are unlikely to be useful for internal errors so lets just discard + // them + err.hint = None; + + anyhow::anyhow!("{err}") + } + Self::ExternalCommandNotFound(..) | Self::CommandRejection => { anyhow::anyhow!("{self}") } Self::Internal(error) => error, diff --git a/crates/but/tests/but/command/branch/new.rs b/crates/but/tests/but/command/branch/new.rs index cb7601f62b8..7eb36723d1c 100644 --- a/crates/but/tests/but/command/branch/new.rs +++ b/crates/but/tests/but/command/branch/new.rs @@ -175,8 +175,8 @@ fn with_json_output() { } #[test] -fn create_new_branch_in_single_branch_mode() { - let env = Sandbox::open_with_default_settings("one-fork"); +fn in_single_branch_mode_creating_stacked_branches() { + let env = Sandbox::open_with_default_settings("single-branch-mode"); env.but("status") .assert() @@ -185,11 +185,10 @@ fn create_new_branch_in_single_branch_mode() { .stdout_eq(str![[r#" ╭┄ zz [uncommitted] (no changes) ┊ -┊╭┄ ma [main] -┊● nmy M (no changes) +┊╭┄ ma [main] (no commits) ├╯ ┊ -┴ e31e6ca (common base) 2000-01-02 add init +┴ b1540e5 (common base) 2000-01-02 M Hint: run `but help` for all commands @@ -214,12 +213,9 @@ Created branch 'middle' ╭┄ zz [uncommitted] (no changes) ┊ ┊╭┄ mi [middle] (no commits) -┊│ -┊├┄ ma [main] -┊● nmy M (no changes) ├╯ ┊ -┴ e31e6ca (common base) 2000-01-02 add init +┴ b1540e5 (common base) 2000-01-02 M Hint: run `but help` for all commands @@ -244,12 +240,9 @@ Created branch 'bottom' below branch 'middle' ┊╭┄ mi [middle] (no commits) ┊│ ┊├┄ bo [bottom] (no commits) -┊│ -┊├┄ ma [main] -┊● nmy M (no changes) ├╯ ┊ -┴ e31e6ca (common base) 2000-01-02 add init +┴ b1540e5 (common base) 2000-01-02 M Hint: run `but help` for all commands @@ -276,12 +269,9 @@ Created branch 'top' above branch 'middle' ┊├┄ mi [middle] (no commits) ┊│ ┊├┄ bo [bottom] (no commits) -┊│ -┊├┄ ma [main] -┊● nmy M (no changes) ├╯ ┊ -┴ e31e6ca (common base) 2000-01-02 add init +┴ b1540e5 (common base) 2000-01-02 M Hint: run `but help` for all commands @@ -310,42 +300,27 @@ Created branch 'between-middle-and-top' above branch 'middle' ┊├┄ mi [middle] (no commits) ┊│ ┊├┄ bo [bottom] (no commits) -┊│ -┊├┄ ma [main] -┊● nmy M (no changes) ├╯ ┊ -┴ e31e6ca (common base) 2000-01-02 add init +┴ b1540e5 (common base) 2000-01-02 M Hint: run `but help` for all commands "#]]); - let repo = env.open_repo(); - - // ensure the branches exist for real - for branch_name in ["top", "between-middle-and-top", "middle", "bottom"] { - let reference_name = format!("refs/heads/{branch_name}"); - assert!( - repo.try_find_reference(reference_name.as_str()) - .unwrap() - .is_some(), - "single-branch creation writes the branch reference" - ); - } + snapbox::assert_data_eq!( + env.git_log(), + snapbox::str![[r#" +* b1540e5 (HEAD -> top, origin/main, origin/HEAD, middle, main, gitbutler/target, bottom, between-middle-and-top) M +* e31e6ca add init - // ensure we didn't create the workspace ref - assert!( - repo.try_find_reference(but_core::WORKSPACE_REF_NAME) - .unwrap() - .is_none(), - "single-branch creation does not create a managed workspace reference" +"#]] ); } #[test] -fn create_new_branches_with_commits_in_single_branch_mode() { - let env = Sandbox::open_with_default_settings("one-fork"); +fn in_single_branch_mode_create_new_branches_with_commits() { + let env = Sandbox::open_with_default_settings("single-branch-mode"); env.but("status") .assert() @@ -354,11 +329,10 @@ fn create_new_branches_with_commits_in_single_branch_mode() { .stdout_eq(str![[r#" ╭┄ zz [uncommitted] (no changes) ┊ -┊╭┄ ma [main] -┊● nmy M (no changes) +┊╭┄ ma [main] (no commits) ├╯ ┊ -┴ e31e6ca (common base) 2000-01-02 add init +┴ b1540e5 (common base) 2000-01-02 M Hint: run `but help` for all commands @@ -385,12 +359,9 @@ Created branch 'middle' ┊ ┊╭┄ mi [middle] ┊● 1 on middle (no changes) -┊│ -┊├┄ ma [main] -┊● nmy M (no changes) ├╯ ┊ -┴ e31e6ca (common base) 2000-01-02 add init +┴ b1540e5 (common base) 2000-01-02 M Hint: run `but help` for all commands @@ -420,23 +391,53 @@ Created branch 'top' above branch 'middle' ┊│ ┊├┄ mi [middle] ┊● 1#1 on middle (no changes) -┊│ -┊├┄ ma [main] -┊● nmy M (no changes) ├╯ ┊ -┴ e31e6ca (common base) 2000-01-02 add init +┴ b1540e5 (common base) 2000-01-02 M Hint: run `but help` for all commands "#]]); + snapbox::assert_data_eq!( + env.git_log(), + snapbox::str![[r#" +* 278079d (HEAD -> top) on top +* 8e3d31a (middle) on middle +* b1540e5 (origin/main, origin/HEAD, main, gitbutler/target) M +* e31e6ca add init + +"#]] + ); + env.but("branch new bottom --below middle") .assert() .success() .stdout_eq(str![[r#" Created branch 'bottom' below branch 'middle' +"#]]); + + env.but("status") + .assert() + .success() + .stderr_eq(str![]) + .stdout_eq(str![[r#" +╭┄ zz [uncommitted] (no changes) +┊ +┊╭┄ to [top] +┊● 1#0 on top (no changes) +┊│ +┊├┄ mi [middle] +┊● 1#1 on middle (no changes) +┊│ +┊├┄ bo [bottom] (no commits) +├╯ +┊ +┴ b1540e5 (common base) 2000-01-02 M + +Hint: run `but help` for all commands + "#]]); env.but("commit --empty -b bottom -m 'on bottom'") @@ -458,12 +459,9 @@ Created branch 'bottom' below branch 'middle' ┊│ ┊├┄ bo [bottom] ┊● 1#2 on bottom (no changes) -┊│ -┊├┄ ma [main] -┊● nmy M (no changes) ├╯ ┊ -┴ e31e6ca (common base) 2000-01-02 add init +┴ b1540e5 (common base) 2000-01-02 M Hint: run `but help` for all commands @@ -499,16 +497,26 @@ Created branch 'between-middle-and-top' above branch 'middle' ┊│ ┊├┄ bo [bottom] ┊● 1#3 on bottom (no changes) -┊│ -┊├┄ ma [main] -┊● nmy M (no changes) ├╯ ┊ -┴ e31e6ca (common base) 2000-01-02 add init +┴ b1540e5 (common base) 2000-01-02 M Hint: run `but help` for all commands "#]]); + + snapbox::assert_data_eq!( + env.git_log(), + snapbox::str![[r#" +* a54744f (HEAD -> top) on top +* 8406d60 (between-middle-and-top) on between-middle-and-top +* 5614135 (middle) on middle +* 9133168 (bottom) on bottom +* b1540e5 (origin/main, origin/HEAD, main, gitbutler/target) M +* e31e6ca add init + +"#]] + ); } #[test] @@ -1088,3 +1096,354 @@ Hint: run `but help` for all commands "#]]); } + +#[test] +fn in_single_branch_mode_creating_new_independent_branch_takes_you_to_workspace_mode() { + let env = Sandbox::open_with_default_settings("single-branch-mode"); + + // at first we're not on a workspace + snapbox::assert_data_eq!( + env.git_log(), + snapbox::str![[r#" +* b1540e5 (HEAD -> main, origin/main, origin/HEAD) M +* e31e6ca add init + +"#]] + ); + + env.but("status").assert().success().stdout_eq(str![[r#" +╭┄ zz [uncommitted] (no changes) +┊ +┊╭┄ ma [main] (no commits) +├╯ +┊ +┴ b1540e5 (common base) 2000-01-02 M + +Hint: run `but help` for all commands + +"#]]); + + // creating a new branch just puts us on that branch + env.but("branch new one") + .assert() + .success() + .stdout_eq(str![[r#" +Created branch 'one' + +"#]]); + + snapbox::assert_data_eq!( + env.git_log(), + snapbox::str![[r#" +* b1540e5 (HEAD -> one, origin/main, origin/HEAD, main, gitbutler/target) M +* e31e6ca add init + +"#]] + ); + + env.but("status").assert().success().stdout_eq(str![[r#" +╭┄ zz [uncommitted] (no changes) +┊ +┊╭┄ on [one] (no commits) +├╯ +┊ +┴ b1540e5 (common base) 2000-01-02 M + +Hint: run `but help` for all commands + +"#]]); + + // creating a second branch puts us into a workspace with both branches applied + env.but("branch new two") + .assert() + .success() + .stderr_eq(str![]) + .stdout_eq(str![[r#" +Created branch 'two' + +"#]]); + + snapbox::assert_data_eq!( + env.git_log(), + snapbox::str![[r#" +* 8ad759d (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|/ +* b1540e5 (origin/main, origin/HEAD, two, one, main, gitbutler/target) M +* e31e6ca add init + +"#]] + ); + + env.but("status").assert().success().stdout_eq(str![[r#" +╭┄ zz [uncommitted] (no changes) +┊ +┊╭┄ tw [two] (no commits) +├╯ +┊ +┊╭┄ on [one] (no commits) +├╯ +┊ +┴ b1540e5 (common base) 2000-01-02 M + +Hint: run `but help` for all commands + +"#]]); + + // switching to a branch removes the workspace and checks out the branch + env.but("switch one").assert().success(); + + snapbox::assert_data_eq!( + env.git_log(), + snapbox::str![[r#" +* 8ad759d (gitbutler/workspace) GitButler Workspace Commit +|/ +* b1540e5 (HEAD -> one, origin/main, origin/HEAD, two, main, gitbutler/target) M +* e31e6ca add init + +"#]] + ); + + env.but("status").assert().success().stdout_eq(str![[r#" +╭┄ zz [uncommitted] (no changes) +┊ +┊╭┄ on [one] (no commits) +├╯ +┊ +┴ b1540e5 (common base) 2000-01-02 M + +Hint: run `but help` for all commands + +"#]]); + + // creating a new branch puts us back on a workspace with the previous and new branches applied + env.but("branch new three") + .assert() + .success() + .stderr_eq(str![]) + .stdout_eq(str![[r#" +Created branch 'three' + +"#]]); + + snapbox::assert_data_eq!( + env.git_log(), + snapbox::str![[r#" +* 9e991f4 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|/ +* b1540e5 (origin/main, origin/HEAD, two, three, one, main, gitbutler/target) M +* e31e6ca add init + +"#]] + ); + + env.but("status").assert().success().stdout_eq(str![[r#" +╭┄ zz [uncommitted] (no changes) +┊ +┊╭┄ th [three] (no commits) +├╯ +┊ +┊╭┄ on [one] (no commits) +├╯ +┊ +┴ b1540e5 (common base) 2000-01-02 M + +Hint: run `but help` for all commands + +"#]]); +} + +#[test] +fn in_single_branch_mode_switching_to_stacked_branches_works() { + let env = Sandbox::open_with_default_settings("single-branch-mode"); + + env.but("branch new bottom").assert().success(); + + env.but("branch new middle --above bottom") + .assert() + .success(); + + env.but("status").assert().success().stdout_eq(str![[r#" +╭┄ zz [uncommitted] (no changes) +┊ +┊╭┄ mi [middle] (no commits) +┊│ +┊├┄ bo [bottom] (no commits) +├╯ +┊ +┴ b1540e5 (common base) 2000-01-02 M + +Hint: run `but help` for all commands + +"#]]); + + snapbox::assert_data_eq!( + env.git_log(), + snapbox::str![[r#" +* b1540e5 (HEAD -> middle, origin/main, origin/HEAD, main, gitbutler/target, bottom) M +* e31e6ca add init + +"#]] + ); + + env.but("switch bottom").assert().success(); + + env.but("status").assert().success().stdout_eq(str![[r#" +╭┄ zz [uncommitted] (no changes) +┊ +┊╭┄ bo [bottom] (no commits) +├╯ +┊ +┴ b1540e5 (common base) 2000-01-02 M + +Hint: run `but help` for all commands + +"#]]); + + snapbox::assert_data_eq!( + env.git_log(), + snapbox::str![[r#" +* b1540e5 (HEAD -> bottom, origin/main, origin/HEAD, middle, main, gitbutler/target) M +* e31e6ca add init + +"#]] + ); + + env.but("branch new new-branch").assert().success(); + + env.but("status").assert().success().stdout_eq(str![[r#" +╭┄ zz [uncommitted] (no changes) +┊ +┊╭┄ ne [new-branch] (no commits) +├╯ +┊ +┊╭┄ bo [bottom] (no commits) +├╯ +┊ +┴ b1540e5 (common base) 2000-01-02 M + +Hint: run `but help` for all commands + +"#]]); + + snapbox::assert_data_eq!( + env.git_log(), + snapbox::str![[r#" +* 10e74ab (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|/ +* b1540e5 (origin/main, origin/HEAD, new-branch, middle, main, gitbutler/target, bottom) M +* e31e6ca add init + +"#]] + ); +} + +#[test] +fn in_single_branch_mode_switching_to_stacked_branches_with_commits_works() { + let env = Sandbox::open_with_default_settings("single-branch-mode"); + + env.but("branch new bottom").assert().success(); + + env.but("commit -m 'on bottom' -b bottom") + .assert() + .success(); + + env.but("branch new middle --above bottom") + .assert() + .success(); + + env.but("commit -m 'on middle' -b middle") + .assert() + .success(); + + env.but("status").assert().success().stdout_eq(str![[r#" +╭┄ zz [uncommitted] (no changes) +┊ +┊╭┄ mi [middle] +┊● 1#0 on middle (no changes) +┊│ +┊├┄ bo [bottom] +┊● 1#1 on bottom (no changes) +├╯ +┊ +┴ b1540e5 (common base) 2000-01-02 M + +Hint: run `but help` for all commands + +"#]]); + + snapbox::assert_data_eq!( + env.git_log(), + snapbox::str![[r#" +* 5614135 (HEAD -> middle) on middle +* 9133168 (bottom) on bottom +* b1540e5 (origin/main, origin/HEAD, main, gitbutler/target) M +* e31e6ca add init + +"#]] + ); + + env.but("switch bottom").assert().success(); + + env.but("status").assert().success().stdout_eq(str![[r#" +╭┄ zz [uncommitted] (no changes) +┊ +┊╭┄ bo [bottom] +┊● 1 on bottom (no changes) +├╯ +┊ +┴ b1540e5 (common base) 2000-01-02 M + +Hint: run `but help` for all commands + +"#]]); + + snapbox::assert_data_eq!( + env.git_log(), + snapbox::str![[r#" +* 5614135 (middle) on middle +* 9133168 (HEAD -> bottom) on bottom +* b1540e5 (origin/main, origin/HEAD, main, gitbutler/target) M +* e31e6ca add init + +"#]] + ); + + env.but("branch new new-branch").assert().success(); + + env.but("commit -m 'on new-branch' -b new-branch") + .assert() + .success(); + + env.but("status").assert().success().stdout_eq(str![[r#" +╭┄ zz [uncommitted] (no changes) +┊ +┊╭┄ ne [new-branch] +┊● 1#0 on new-branch (no changes) +├╯ +┊ +┊╭┄ bo [bottom] +┊● 1#1 on bottom (no changes) +├╯ +┊ +┴ b1540e5 (common base) 2000-01-02 M + +Hint: run `but help` for all commands + +"#]]); + + snapbox::assert_data_eq!( + env.git_log(), + snapbox::str![[r#" +* ca16105 (HEAD -> gitbutler/workspace) GitButler Workspace Commit +|/ +* | f6cc4a5 (new-branch) on new-branch +| | * 5614135 (middle) on middle +| |/ +| * 9133168 (bottom) on bottom +|/ +* b1540e5 (origin/main, origin/HEAD, main, gitbutler/target) M +* e31e6ca add init + +"#]] + ); +} diff --git a/crates/but/tests/but/command/commit.rs b/crates/but/tests/but/command/commit.rs index e85d93fed6c..c0211ad6943 100644 --- a/crates/but/tests/but/command/commit.rs +++ b/crates/but/tests/but/command/commit.rs @@ -186,7 +186,7 @@ fn commits_at_each_branch_in_an_existing_single_branch_stack() { env.but("commit --empty -b middle -m 'middle base'") .assert() .success(); - env.but("branch new top").assert().success(); + env.but("branch new top --above middle").assert().success(); env.but("commit --empty -b top -m 'top base'") .assert() .success(); diff --git a/crates/but/tests/but/command/pull.rs b/crates/but/tests/but/command/pull.rs index dd3fa39a5b8..b008b18519a 100644 --- a/crates/but/tests/but/command/pull.rs +++ b/crates/but/tests/but/command/pull.rs @@ -140,7 +140,7 @@ fn single_branch_pull_prunes_an_integrated_lower_branch() { let env = single_branch_integration_scenario(); env.but("branch new C").assert().success(); commit_file(&env, "C"); - env.but("branch new A").assert().success(); + env.but("branch new A --above C").assert().success(); commit_file(&env, "A"); let old_head = rev_parse(&env, "A"); let old_lower = rev_parse(&env, "C"); @@ -232,7 +232,7 @@ fn single_branch_pull_keeps_an_empty_branch_above_an_integrated_branch() { let env = single_branch_integration_scenario(); env.but("branch new bottom").assert().success(); commit_file(&env, "bottom"); - env.but("branch new top").assert().success(); + env.but("branch new top --above bottom").assert().success(); let old_tip = rev_parse(&env, "top"); merge_into_upstream(&env, "bottom", false); diff --git a/crates/but/tests/but/command/push.rs b/crates/but/tests/but/command/push.rs index 818b5721e5d..12c002093ee 100644 --- a/crates/but/tests/but/command/push.rs +++ b/crates/but/tests/but/command/push.rs @@ -327,6 +327,5 @@ Error: Branch 'A' is merged upstream Hint: Most likely you want `but pull`, which updates the workspace and removes landed work. In rare cases `--allow-merged` can bypass this check - "#]]); } diff --git a/crates/but/tests/fixtures/scenario/single-branch-mode.sh b/crates/but/tests/fixtures/scenario/single-branch-mode.sh new file mode 100644 index 00000000000..6a02dda6810 --- /dev/null +++ b/crates/but/tests/fixtures/scenario/single-branch-mode.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +set -eu -o pipefail + +source "${BASH_SOURCE[0]%/*}/shared.sh" + +### General Description + +# A repo in single branch mode whose main and origin/main tips match. + +git-init-frozen + +commit-file init + +git checkout main + commit M + +setup_target_to_match_main