Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 38 additions & 11 deletions crates/but-api/src/branch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<BranchCheckoutResult> {
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.
///
Expand All @@ -878,22 +887,33 @@ 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<BranchCheckoutResult> {
{
let repo = ctx.repo.get()?;
let current_head = repo
.head_id()
.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,
Expand All @@ -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)?;
Expand Down
64 changes: 64 additions & 0 deletions crates/but/src/args/atoms/branch_arg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<FullName> {
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<Option<FullName>> {
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,
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions crates/but/src/args/atoms/cli_id.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<gix::refs::FullName> {
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.
Expand Down
1 change: 1 addition & 0 deletions crates/but/src/args/metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ pub enum CommandName {
BranchUpdate,
BranchMove,
BranchTearOff,
Switch,
Worktree,
Mark,
Unmark,
Expand Down
47 changes: 45 additions & 2 deletions crates/but/src/args/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -1097,6 +1095,51 @@ pub enum Subcommands {
target_branch: Option<String>,
},

/// 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<CliIdArg>,
/// 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
Expand Down
1 change: 1 addition & 0 deletions crates/but/src/command/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down
1 change: 1 addition & 0 deletions crates/but/src/command/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ pub mod r#move;
pub mod onboarding;
pub mod push;
pub mod skill;
pub mod r#switch;
pub mod update;
55 changes: 55 additions & 0 deletions crates/but/src/command/switch.rs
Original file line number Diff line number Diff line change
@@ -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<CliIdArg>,
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<String> {
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())
}
9 changes: 9 additions & 0 deletions crates/but/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions crates/but/src/utils/metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
1 change: 1 addition & 0 deletions crates/but/tests/but/command/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ mod skill;
mod squash;
#[cfg(feature = "legacy")]
mod status;
mod r#switch;
#[cfg(feature = "legacy")]
mod teardown;
#[cfg(feature = "legacy")]
Expand Down
Loading
Loading