diff --git a/crates/but-api/src/branch.rs b/crates/but-api/src/branch.rs index 4f7c41ca8b5..939af5b41a8 100644 --- a/crates/but-api/src/branch.rs +++ b/crates/but-api/src/branch.rs @@ -5,7 +5,7 @@ use anyhow::{Context as _, bail}; use bstr::ByteSlice; use but_api_macros::but_api; use but_core::{ - DryRun, + DryRun, WORKSPACE_REF_NAME, branch::unique_canned_refname, ref_metadata::{ProjectMeta, StackId}, sync::RepoExclusive, @@ -860,6 +860,15 @@ pub fn branch_checkout_new_with_perm( branch_checkout_with_perm(ctx, branch, perm) } +/// Checks out the GitButler workspace reference under caller-held exclusive repository access. +pub fn workspace_checkout_with_perm( + ctx: &mut but_ctx::Context, + perm: &mut RepoExclusive, +) -> anyhow::Result { + let workspace_ref: gix::refs::FullName = WORKSPACE_REF_NAME.try_into()?; + checkout_ref_with_perm(ctx, workspace_ref, perm) +} + /// Checks out an existing local branch under caller-held exclusive repository /// access. /// @@ -878,6 +887,14 @@ pub fn branch_checkout_with_perm( ); } + checkout_ref_with_perm(ctx, branch, perm) +} + +fn checkout_ref_with_perm( + ctx: &mut but_ctx::Context, + reference_name: gix::refs::FullName, + perm: &mut RepoExclusive, +) -> anyhow::Result { { let repo = ctx.repo.get()?; let current_head = repo @@ -885,15 +902,18 @@ pub fn branch_checkout_with_perm( .context("Cannot check out a branch while HEAD is unborn")? .detach(); let mut reference = repo - .find_reference(branch.as_ref()) - .with_context(|| format!("Could not find branch '{}'", branch.as_bstr()))?; + .find_reference(reference_name.as_ref()) + .with_context(|| format!("Could not find ref '{}'", reference_name.as_bstr()))?; let target = reference .peel_to_id() - .with_context(|| format!("Could not resolve branch '{}'", branch.as_bstr()))? + .with_context(|| format!("Could not resolve ref '{}'", reference_name.as_bstr()))? .detach(); - let target_commit = repo - .find_commit(target) - .with_context(|| format!("Branch '{}' does not point to a commit", branch.as_bstr()))?; + let target_commit = repo.find_commit(target).with_context(|| { + format!( + "Ref '{}' does not point to a commit", + reference_name.as_bstr() + ) + })?; safe_checkout( current_head, @@ -904,15 +924,22 @@ pub fn branch_checkout_with_perm( uncommitted_changes: UncommitedWorktreeChanges::KeepAndAbortOnConflict, ..Default::default() }, - )?; + ) + .with_context(|| { + format!( + "Could not safely check out '{}' from {current_head} to {target}", + reference_name.as_bstr() + ) + })?; update_head_reference( &repo, - gix::refs::Target::Symbolic(branch.clone()), + gix::refs::Target::Symbolic(reference_name.clone()), false, "checkout", - branch.as_bstr(), + reference_name.as_bstr(), target_commit.parent_ids().count(), - )?; + ) + .with_context(|| format!("Could not update HEAD to '{}'", reference_name.as_bstr()))?; } ctx.reload_repo_and_invalidate_workspace(perm)?; diff --git a/crates/but/src/args/atoms/branch_arg.rs b/crates/but/src/args/atoms/branch_arg.rs index df0a74beecc..1e06805983e 100644 --- a/crates/but/src/args/atoms/branch_arg.rs +++ b/crates/but/src/args/atoms/branch_arg.rs @@ -128,9 +128,54 @@ impl BranchArg { Err(bad_input(format!("Branch '{self}' not found")).into()) } + /// Resolve the argument to an existing local branch reference. + pub fn resolve_existing_local_branch(&self, repo: &gix::Repository) -> CliResult { + if let Some(branch) = self.try_resolve_existing_local_branch(repo)? { + return Ok(branch); + } + Err(bad_input(format!("Could not find branch: '{self}'")).into()) + } + + /// Try to resolve the argument to an existing local branch reference. + /// + /// Returns `Ok(None)` when the argument is not an existing branch and does not look like a + /// remote branch. + pub fn try_resolve_existing_local_branch( + &self, + repo: &gix::Repository, + ) -> CliResult> { + if self.0.starts_with("refs/heads/") { + let branch = FullName::try_from(self.0.as_str()) + .map_err(|_| bad_input(format!("Invalid branch ref '{self}'")))?; + ensure_existing_local_branch(repo, &branch)?; + return Ok(Some(branch)); + } + + if self.0.starts_with("refs/remotes/") { + return Err( + bad_input(format!("Can only switch to local branches, got '{self}'")).into(), + ); + } + + if let Ok(branch) = Category::LocalBranch.to_full_name(self.0.as_str()) + && repo.try_find_reference(branch.as_ref())?.is_some() + { + return Ok(Some(branch)); + } + + if looks_like_remote_branch(repo, self.0.as_str()) { + return Err( + bad_input(format!("Can only switch to local branches, got '{self}'")).into(), + ); + } + + Ok(None) + } + /// Try to resolve the branch to a stack that exists in the workspace. /// /// Returns `None` if the branch can't be found which might be caused it not being applied. + #[cfg(feature = "legacy")] pub fn try_resolve_stack( &self, ctx: &but_ctx::Context, @@ -166,6 +211,25 @@ fn resolve_branch_ref( })) } +fn ensure_existing_local_branch(repo: &gix::Repository, branch: &FullName) -> CliResult<()> { + if !branch.as_bstr().starts_with_str("refs/heads/") { + return Err(bad_input(format!("Can only switch to local branches, got '{branch}'")).into()); + } + if repo.try_find_reference(branch.as_ref())?.is_none() { + return Err(bad_input(format!("Branch '{}' not found", branch.shorten())).into()); + } + Ok(()) +} + +fn looks_like_remote_branch(repo: &gix::Repository, target: &str) -> bool { + repo.remote_names().iter().any(|remote| { + target + .as_bytes() + .strip_prefix(remote.as_bstr().as_bytes()) + .is_some_and(|rest| rest.starts_with(b"/")) + }) +} + #[expect(missing_docs, reason = "only used internally by CLI command helpers")] pub struct ResolvedBranchRef { pub head: gix::ObjectId, diff --git a/crates/but/src/args/atoms/cli_id.rs b/crates/but/src/args/atoms/cli_id.rs index f2ca58d8767..23e8a96e107 100644 --- a/crates/but/src/args/atoms/cli_id.rs +++ b/crates/but/src/args/atoms/cli_id.rs @@ -111,6 +111,21 @@ impl CliIdArg { } } + /// Resolve the argument to an existing local branch reference or workspace branch CLI ID. + pub fn resolve_existing_local_branch( + &self, + repo: &gix::Repository, + id_map: &IdMap, + ) -> CliResult { + let branch = BranchArg(self.0.clone()); + if let Some(branch_ref) = branch.try_resolve_existing_local_branch(repo)? { + return Ok(branch_ref); + } + + self.resolve_branch_in_workspace(repo, id_map)? + .resolve_existing_local_branch(repo) + } + /// Try and resolve the argument a branch that might exist in the workspace. /// /// Returns `Ok(None)` if it doesn't exist in the workspace. diff --git a/crates/but/src/args/metrics.rs b/crates/but/src/args/metrics.rs index b41384426e5..7f22210f13a 100644 --- a/crates/but/src/args/metrics.rs +++ b/crates/but/src/args/metrics.rs @@ -42,6 +42,7 @@ pub enum CommandName { BranchUpdate, BranchMove, BranchTearOff, + Switch, Worktree, Mark, Unmark, diff --git a/crates/but/src/args/mod.rs b/crates/but/src/args/mod.rs index 2a4062950d8..f51fb06c83d 100644 --- a/crates/but/src/args/mod.rs +++ b/crates/but/src/args/mod.rs @@ -11,10 +11,8 @@ use std::ffi::OsString; use std::path::PathBuf; -#[cfg(feature = "legacy")] use crate::args::atoms::CliIdArg; -#[cfg(feature = "legacy")] pub mod atoms; #[derive(Debug, clap::Parser)] @@ -1097,6 +1095,51 @@ pub enum Subcommands { target_branch: Option, }, + /// Switch to a local branch, workspace branch ID, or the GitButler workspace. + /// + /// ## Examples + /// + /// Switch to a branch: + /// + /// ```text + /// but switch my-feature + /// ``` + /// + /// Switch back to the GitButler workspace: + /// + /// ```text + /// but switch --workspace + /// ``` + /// + /// Create a new branch at the project target and switch to it: + /// + /// ```text + /// but switch --new + /// ``` + /// + /// Create a named branch at the project target and switch to it: + /// + /// ```text + /// but switch --new my-feature + /// ``` + #[cfg_attr(feature = "raw-clap-docs", clap(verbatim_doc_comment))] + #[clap(hide = true, group( + clap::ArgGroup::new("switch_target") + .args(["target", "workspace", "new"]) + .required(true) + .multiple(true) + ))] + Switch { + /// Branch name, full local branch ref, workspace CLI branch ID, or new branch name with --new + target: Option, + /// Switch back to gitbutler/workspace + #[clap(long, short = 'w', conflicts_with_all = &["target", "new"])] + workspace: bool, + /// Create a branch at the project target and switch to it + #[clap(long = "new", short = 'n')] + new: bool, + }, + /// Manage AI agent skills for GitButler. /// /// Skills provide enhanced AI capabilities for working with GitButler through diff --git a/crates/but/src/command/help.rs b/crates/but/src/command/help.rs index f73faa06de6..66b103ee90f 100644 --- a/crates/but/src/command/help.rs +++ b/crates/but/src/command/help.rs @@ -106,6 +106,7 @@ fn print_grouped_with_truncation( SubcommandDiscriminant::Clean => Group::BranchingAndCommitting, #[cfg(feature = "legacy")] SubcommandDiscriminant::Pick => Group::BranchingAndCommitting, + SubcommandDiscriminant::Switch => Group::BranchingAndCommitting, #[cfg(feature = "legacy")] SubcommandDiscriminant::Resolve => Group::BranchingAndCommitting, diff --git a/crates/but/src/command/mod.rs b/crates/but/src/command/mod.rs index 91bb03e1051..09a806fb743 100644 --- a/crates/but/src/command/mod.rs +++ b/crates/but/src/command/mod.rs @@ -16,4 +16,5 @@ pub mod r#move; pub mod onboarding; pub mod push; pub mod skill; +pub mod r#switch; pub mod update; diff --git a/crates/but/src/command/switch.rs b/crates/but/src/command/switch.rs new file mode 100644 index 00000000000..3eb456ca915 --- /dev/null +++ b/crates/but/src/command/switch.rs @@ -0,0 +1,55 @@ +use crate::{CliResult, IdMap, args::atoms::CliIdArg, utils::OutputChannel}; + +pub fn handle( + ctx: &mut but_ctx::Context, + out: &mut OutputChannel, + target: Option, + workspace: bool, + new: bool, +) -> CliResult<()> { + let mut guard = ctx.exclusive_worktree_access(); + + if workspace { + but_api::branch::workspace_checkout_with_perm(ctx, guard.write_permission())?; + if let Some(out) = out.for_human() { + writeln!(out, "Switched to workspace")?; + } + return Ok(()); + } + + if new { + let requested_name = target.map(|target| target.0); + but_api::branch::branch_checkout_new_with_perm( + ctx, + requested_name, + guard.write_permission(), + )?; + let branch_name = current_head_short_name(ctx)?; + if let Some(out) = out.for_human() { + writeln!(out, "Created and switched to branch '{branch_name}'")?; + } + return Ok(()); + } + + let target = target + .ok_or_else(|| anyhow::anyhow!("BUG: clap requires target, --workspace, or --new"))?; + let branch = { + let repo = ctx.repo.get()?; + let id_map = IdMap::new_from_context(ctx, None, guard.read_permission())?; + target.resolve_existing_local_branch(&repo, &id_map)? + }; + but_api::branch::branch_checkout_with_perm(ctx, branch.clone(), guard.write_permission())?; + + if let Some(out) = out.for_human() { + writeln!(out, "Switched to branch '{}'", branch.shorten())?; + } + Ok(()) +} + +fn current_head_short_name(ctx: &but_ctx::Context) -> CliResult { + let repo = ctx.repo.get()?; + let head_name = repo + .head_name()? + .ok_or_else(|| anyhow::anyhow!("HEAD is detached after switching branches"))?; + Ok(head_name.shorten().to_string()) +} diff --git a/crates/but/src/lib.rs b/crates/but/src/lib.rs index 718ff8ceb26..07f6d366a92 100644 --- a/crates/but/src/lib.rs +++ b/crates/but/src/lib.rs @@ -630,6 +630,15 @@ async fn match_subcommand( }; result.emit_metrics(metrics_ctx) } + Subcommands::Switch { + target, + workspace, + new, + } => { + let mut ctx = but_ctx::Context::discover(&args.current_dir)?; + command::r#switch::handle(&mut ctx, out, target, workspace, new) + .emit_metrics(metrics_ctx) + } #[cfg(feature = "legacy")] Subcommands::Mcp => command::legacy::mcp::start(app_settings) .await diff --git a/crates/but/src/utils/metrics.rs b/crates/but/src/utils/metrics.rs index 89fd903ffb2..d3220893cac 100644 --- a/crates/but/src/utils/metrics.rs +++ b/crates/but/src/utils/metrics.rs @@ -115,6 +115,7 @@ impl Subcommands { Subcommands::Unapply { .. } => BranchUnapply, #[cfg(feature = "legacy")] Subcommands::Apply { .. } => BranchApply, + Subcommands::Switch { .. } => Switch, #[cfg(feature = "legacy")] Subcommands::Worktree(worktree::Platform { cmd: _ }) => Worktree, #[cfg(feature = "legacy")] diff --git a/crates/but/tests/but/command/mod.rs b/crates/but/tests/but/command/mod.rs index 32b064f0174..f19641de996 100644 --- a/crates/but/tests/but/command/mod.rs +++ b/crates/but/tests/but/command/mod.rs @@ -47,6 +47,7 @@ mod skill; mod squash; #[cfg(feature = "legacy")] mod status; +mod r#switch; #[cfg(feature = "legacy")] mod teardown; #[cfg(feature = "legacy")] diff --git a/crates/but/tests/but/command/switch.rs b/crates/but/tests/but/command/switch.rs new file mode 100644 index 00000000000..9483c934465 --- /dev/null +++ b/crates/but/tests/but/command/switch.rs @@ -0,0 +1,251 @@ +use snapbox::str; + +#[cfg(feature = "legacy")] +use crate::utils::CommandExt as _; + +#[test] +fn switches_to_existing_branch_by_short_name() -> anyhow::Result<()> { + let env = switch_env()?; + + #[cfg(feature = "legacy")] + assert_workspace_status(&env); + + env.but("switch A") + .assert() + .success() + .stderr_eq(str![]) + .stdout_eq(str![[r#" +Switched to branch 'A' + +"#]]); + + assert_eq!(env.invoke_git("rev-parse --abbrev-ref HEAD"), "A"); + Ok(()) +} + +#[test] +fn switches_to_existing_branch_by_full_ref() -> anyhow::Result<()> { + let env = switch_env()?; + + #[cfg(feature = "legacy")] + assert_workspace_status(&env); + + env.but("switch refs/heads/A") + .assert() + .success() + .stderr_eq(str![]) + .stdout_eq(str![[r#" +Switched to branch 'A' + +"#]]); + + assert_eq!(env.invoke_git("rev-parse --abbrev-ref HEAD"), "A"); + Ok(()) +} + +#[test] +fn switches_to_existing_branch_with_remote_like_name() -> anyhow::Result<()> { + let env = switch_env()?; + env.invoke_git("branch origin/main main"); + + env.but("switch origin/main") + .assert() + .success() + .stderr_eq(str![]) + .stdout_eq(str![[r#" +Switched to branch 'origin/main' + +"#]]); + + assert_eq!( + env.invoke_git("rev-parse --symbolic-full-name HEAD"), + "refs/heads/origin/main" + ); + Ok(()) +} + +#[cfg(feature = "legacy")] +#[test] +fn switches_to_existing_branch_by_workspace_cli_id() -> anyhow::Result<()> { + let env = switch_env()?; + + assert_workspace_status(&env); + + let status = status_json(&env)?; + let branch_cli_id = status["stacks"][0]["branches"][0]["cliId"] + .as_str() + .expect("branch cli id should exist"); + + env.but(format!("switch {branch_cli_id}")) + .assert() + .success() + .stderr_eq(str![]) + .stdout_eq(str![[r#" +Switched to branch 'A' + +"#]]); + + assert_eq!(env.invoke_git("rev-parse --abbrev-ref HEAD"), "A"); + Ok(()) +} + +#[test] +fn switches_back_to_workspace() -> anyhow::Result<()> { + let env = switch_env()?; + env.invoke_git("checkout A"); + + env.but("switch --workspace") + .assert() + .success() + .stderr_eq(str![]) + .stdout_eq(str![[r#" +Switched to workspace + +"#]]); + + assert_eq!( + env.invoke_git("rev-parse --abbrev-ref HEAD"), + "gitbutler/workspace" + ); + + #[cfg(feature = "legacy")] + assert_workspace_status(&env); + Ok(()) +} + +#[test] +fn creates_named_branch_and_switches_to_it() -> anyhow::Result<()> { + let env = switch_env()?; + + #[cfg(feature = "legacy")] + assert_workspace_status(&env); + + env.but("switch --new my-feature") + .assert() + .success() + .stderr_eq(str![]) + .stdout_eq(str![[r#" +Created and switched to branch 'my-feature' + +"#]]); + + assert_eq!(env.invoke_git("rev-parse --abbrev-ref HEAD"), "my-feature"); + assert_eq!( + env.invoke_git("rev-parse my-feature"), + env.invoke_git("rev-parse main") + ); + Ok(()) +} + +#[test] +fn creates_generated_branch_and_switches_to_it() -> anyhow::Result<()> { + let env = switch_env()?; + + #[cfg(feature = "legacy")] + assert_workspace_status(&env); + + env.but("switch --new") + .assert() + .success() + .stderr_eq(str![]) + .stdout_eq(str![[r#" +Created and switched to branch 'a-branch-1' + +"#]]); + + assert_eq!(env.invoke_git("rev-parse --abbrev-ref HEAD"), "a-branch-1"); + assert_eq!( + env.invoke_git("rev-parse a-branch-1"), + env.invoke_git("rev-parse main") + ); + Ok(()) +} + +#[test] +fn rejects_workspace_with_target() -> anyhow::Result<()> { + let env = switch_env()?; + + env.but("switch --workspace A") + .assert() + .failure() + .stdout_eq(str![]) + .stderr_eq(str![[r#" +error: the argument '--workspace' cannot be used with '[TARGET]' + +Usage: but switch + +For more information, try '--help'. + +"#]]); + + Ok(()) +} + +#[test] +fn rejects_remote_branch() -> anyhow::Result<()> { + let env = switch_env()?; + + env.but("switch origin/main") + .assert() + .failure() + .stdout_eq(str![]) + .stderr_eq(str![[r#" +Error: Can only switch to local branches, got 'origin/main' + +"#]]); + + Ok(()) +} + +#[cfg(feature = "legacy")] +#[test] +fn rejects_non_branch_cli_id() -> anyhow::Result<()> { + let env = switch_env()?; + let status = status_json(&env)?; + let commit_cli_id = status["stacks"][0]["branches"][0]["commits"][0]["cliId"] + .as_str() + .expect("commit cli id should exist"); + + env.but(format!("switch {commit_cli_id}")) + .assert() + .failure() + .stdout_eq(str![]) + .stderr_eq(format!( + "Error: Invalid branch. '{commit_cli_id}' is a commit\n" + )); + + Ok(()) +} + +fn switch_env() -> anyhow::Result { + let env = crate::utils::Sandbox::init_scenario_with_target_and_default_settings("one-stack")?; + env.setup_metadata(&["A"])?; + Ok(env) +} + +#[cfg(feature = "legacy")] +fn status_json(env: &crate::utils::Sandbox) -> anyhow::Result { + let output = env.but("--format json status").allow_json().output()?; + serde_json::from_slice(&output.stdout) + .map_err(|err| anyhow::anyhow!("status output should be valid JSON: {err}")) +} + +#[cfg(feature = "legacy")] +fn assert_workspace_status(env: &crate::utils::Sandbox) { + env.but("status") + .assert() + .success() + .stderr_eq(str![]) + .stdout_eq(str![[r#" +╭┄zz [unassigned changes] (no changes) +┊ +┊╭┄g0 [A] +┊● 9477ae7 add A +├╯ +┊ +┴ 0dc3733 (common base) 2000-01-02 add M + +Hint: run `but help` for all commands + +"#]]); +}