Skip to content

Commit 8ca6d40

Browse files
committed
but: switch command
Add a command to switch to an new or existing ref. And back to the workspace.
1 parent 0476a7a commit 8ca6d40

11 files changed

Lines changed: 447 additions & 13 deletions

File tree

crates/but-api/src/branch.rs

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use anyhow::{Context as _, bail};
55
use bstr::ByteSlice;
66
use but_api_macros::but_api;
77
use but_core::{
8-
DryRun,
8+
DryRun, WORKSPACE_REF_NAME,
99
branch::unique_canned_refname,
1010
ref_metadata::{ProjectMeta, StackId},
1111
sync::RepoExclusive,
@@ -835,6 +835,15 @@ pub fn branch_checkout_new_with_perm(
835835
branch_checkout_with_perm(ctx, branch, perm)
836836
}
837837

838+
/// Checks out the GitButler workspace reference under caller-held exclusive repository access.
839+
pub fn workspace_checkout_with_perm(
840+
ctx: &mut but_ctx::Context,
841+
perm: &mut RepoExclusive,
842+
) -> anyhow::Result<BranchCheckoutResult> {
843+
let workspace_ref: gix::refs::FullName = WORKSPACE_REF_NAME.try_into()?;
844+
checkout_ref_with_perm(ctx, workspace_ref, perm)
845+
}
846+
838847
/// Checks out an existing local branch under caller-held exclusive repository
839848
/// access.
840849
///
@@ -853,22 +862,33 @@ pub fn branch_checkout_with_perm(
853862
);
854863
}
855864

865+
checkout_ref_with_perm(ctx, branch, perm)
866+
}
867+
868+
fn checkout_ref_with_perm(
869+
ctx: &mut but_ctx::Context,
870+
reference_name: gix::refs::FullName,
871+
perm: &mut RepoExclusive,
872+
) -> anyhow::Result<BranchCheckoutResult> {
856873
{
857874
let repo = ctx.repo.get()?;
858875
let current_head = repo
859876
.head_id()
860877
.context("Cannot check out a branch while HEAD is unborn")?
861878
.detach();
862879
let mut reference = repo
863-
.find_reference(branch.as_ref())
864-
.with_context(|| format!("Could not find branch '{}'", branch.as_bstr()))?;
880+
.find_reference(reference_name.as_ref())
881+
.with_context(|| format!("Could not find ref '{}'", reference_name.as_bstr()))?;
865882
let target = reference
866883
.peel_to_id()
867-
.with_context(|| format!("Could not resolve branch '{}'", branch.as_bstr()))?
884+
.with_context(|| format!("Could not resolve ref '{}'", reference_name.as_bstr()))?
868885
.detach();
869-
let target_commit = repo
870-
.find_commit(target)
871-
.with_context(|| format!("Branch '{}' does not point to a commit", branch.as_bstr()))?;
886+
let target_commit = repo.find_commit(target).with_context(|| {
887+
format!(
888+
"Ref '{}' does not point to a commit",
889+
reference_name.as_bstr()
890+
)
891+
})?;
872892

873893
safe_checkout(
874894
current_head,
@@ -879,15 +899,22 @@ pub fn branch_checkout_with_perm(
879899
uncommitted_changes: UncommitedWorktreeChanges::KeepAndAbortOnConflict,
880900
..Default::default()
881901
},
882-
)?;
902+
)
903+
.with_context(|| {
904+
format!(
905+
"Could not safely check out '{}' from {current_head} to {target}",
906+
reference_name.as_bstr()
907+
)
908+
})?;
883909
update_head_reference(
884910
&repo,
885-
gix::refs::Target::Symbolic(branch.clone()),
911+
gix::refs::Target::Symbolic(reference_name.clone()),
886912
false,
887913
"checkout",
888-
branch.as_bstr(),
914+
reference_name.as_bstr(),
889915
target_commit.parent_ids().count(),
890-
)?;
916+
)
917+
.with_context(|| format!("Could not update HEAD to '{}'", reference_name.as_bstr()))?;
891918
}
892919

893920
ctx.reload_repo_and_invalidate_workspace(perm)?;

crates/but/src/args/atoms/branch_arg.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ impl BranchArg {
131131
/// Try to resolve the branch to a stack that exists in the workspace.
132132
///
133133
/// Returns `None` if the branch can't be found which might be caused it not being applied.
134+
#[cfg(feature = "legacy")]
134135
pub fn try_resolve_stack(
135136
&self,
136137
ctx: &but_ctx::Context,

crates/but/src/args/metrics.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ pub enum CommandName {
4242
BranchUpdate,
4343
BranchMove,
4444
BranchTearOff,
45+
Switch,
4546
Worktree,
4647
Mark,
4748
Unmark,

crates/but/src/args/mod.rs

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,8 @@
1111
use std::ffi::OsString;
1212
use std::path::PathBuf;
1313

14-
#[cfg(feature = "legacy")]
1514
use crate::args::atoms::CliIdArg;
1615

17-
#[cfg(feature = "legacy")]
1816
pub mod atoms;
1917

2018
#[derive(Debug, clap::Parser)]
@@ -1084,6 +1082,45 @@ pub enum Subcommands {
10841082
target_branch: Option<String>,
10851083
},
10861084

1085+
/// Switch to a local branch, workspace branch ID, or the GitButler workspace.
1086+
///
1087+
/// ## Examples
1088+
///
1089+
/// Switch to a branch:
1090+
///
1091+
/// ```text
1092+
/// but switch my-feature
1093+
/// ```
1094+
///
1095+
/// Switch back to the GitButler workspace:
1096+
///
1097+
/// ```text
1098+
/// but switch --workspace
1099+
/// ```
1100+
///
1101+
/// Create a new branch at the target and switch to it:
1102+
///
1103+
/// ```text
1104+
/// but switch --new my-feature
1105+
/// ```
1106+
#[cfg_attr(feature = "raw-clap-docs", clap(verbatim_doc_comment))]
1107+
#[clap(group(
1108+
clap::ArgGroup::new("switch_target")
1109+
.args(["target", "workspace", "new"])
1110+
.required(true)
1111+
.multiple(true)
1112+
))]
1113+
Switch {
1114+
/// Branch name, full local branch ref, or workspace CLI branch ID
1115+
target: Option<CliIdArg>,
1116+
/// Switch back to gitbutler/workspace
1117+
#[clap(long, short = 'w', conflicts_with_all = &["target", "new"])]
1118+
workspace: bool,
1119+
/// Create a new branch at the target and switch to it
1120+
#[clap(long = "new", short = 'n')]
1121+
new: bool,
1122+
},
1123+
10871124
/// Manage AI agent skills for GitButler.
10881125
///
10891126
/// Skills provide enhanced AI capabilities for working with GitButler through

crates/but/src/command/help.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ fn print_grouped_with_truncation(
106106
SubcommandDiscriminant::Clean => Group::BranchingAndCommitting,
107107
#[cfg(feature = "legacy")]
108108
SubcommandDiscriminant::Pick => Group::BranchingAndCommitting,
109+
SubcommandDiscriminant::Switch => Group::BranchingAndCommitting,
109110
#[cfg(feature = "legacy")]
110111
SubcommandDiscriminant::Resolve => Group::BranchingAndCommitting,
111112

@@ -328,6 +329,7 @@ Branching and Committing:
328329
apply Apply a branch to the workspace
329330
clean Remove empty branches from the workspace
330331
pick Cherry-pick a commit from an unapplied branch into an applied v…
332+
switch Switch to a local branch, workspace branch ID, or the GitButler…
331333
332334
Editing Commits:
333335
rub Combines two entities together to perform an operation like ame…

crates/but/src/command/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ pub mod r#move;
1616
pub mod onboarding;
1717
pub mod push;
1818
pub mod skill;
19+
pub mod r#switch;
1920
pub mod update;

crates/but/src/command/switch.rs

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
use bstr::ByteSlice;
2+
use gix::refs::{Category, FullName};
3+
4+
use crate::{CliId, CliResult, IdMap, args::atoms::CliIdArg, bad_input, utils::OutputChannel};
5+
6+
pub fn handle(
7+
ctx: &mut but_ctx::Context,
8+
out: &mut OutputChannel,
9+
target: Option<CliIdArg>,
10+
workspace: bool,
11+
new: bool,
12+
) -> CliResult<()> {
13+
let mut guard = ctx.exclusive_worktree_access();
14+
15+
if workspace {
16+
but_api::branch::workspace_checkout_with_perm(ctx, guard.write_permission())?;
17+
if let Some(out) = out.for_human() {
18+
writeln!(out, "Switched to workspace")?;
19+
}
20+
return Ok(());
21+
}
22+
23+
if new {
24+
let requested_name = target.map(|target| target.0);
25+
but_api::branch::branch_checkout_new_with_perm(
26+
ctx,
27+
requested_name,
28+
guard.write_permission(),
29+
)?;
30+
let branch_name = current_head_short_name(ctx)?;
31+
if let Some(out) = out.for_human() {
32+
writeln!(out, "Created and switched to branch '{branch_name}'")?;
33+
}
34+
return Ok(());
35+
}
36+
37+
let target = target
38+
.ok_or_else(|| anyhow::anyhow!("BUG: clap requires target, --workspace, or --new"))?;
39+
let branch = resolve_existing_local_branch(ctx, guard.read_permission(), &target)?;
40+
but_api::branch::branch_checkout_with_perm(ctx, branch.clone(), guard.write_permission())?;
41+
42+
if let Some(out) = out.for_human() {
43+
writeln!(out, "Switched to branch '{}'", branch.shorten())?;
44+
}
45+
Ok(())
46+
}
47+
48+
fn resolve_existing_local_branch(
49+
ctx: &but_ctx::Context,
50+
perm: &but_core::sync::RepoShared,
51+
target: &CliIdArg,
52+
) -> CliResult<FullName> {
53+
let repo = ctx.repo.get()?;
54+
55+
if target.0.starts_with("refs/heads/") {
56+
let full_name = FullName::try_from(target.0.as_str())
57+
.map_err(|_| bad_input(format!("Invalid branch ref '{}'", target.0)))?;
58+
ensure_existing_local_branch(&repo, &full_name)?;
59+
return Ok(full_name);
60+
}
61+
62+
if target.0.starts_with("refs/remotes/") || looks_like_remote_branch(&repo, &target.0) {
63+
return Err(bad_input(format!(
64+
"Can only switch to local branches, got '{}'",
65+
target.0
66+
))
67+
.into());
68+
}
69+
70+
if let Ok(short_name) = Category::LocalBranch.to_full_name(target.0.as_str())
71+
&& repo.try_find_reference(short_name.as_ref())?.is_some()
72+
{
73+
return Ok(short_name);
74+
}
75+
76+
let id_map = IdMap::new_from_context(ctx, None, perm)?;
77+
let matches = id_map.parse_using_context(&target.0, ctx)?;
78+
if matches.is_empty() {
79+
return Err(bad_input(format!("Could not find branch: '{}'", target.0)).into());
80+
}
81+
if matches.len() > 1 {
82+
return Err(anyhow::anyhow!(
83+
"Branch '{}' is ambiguous. Try using more characters to disambiguate.",
84+
target.0
85+
)
86+
.into());
87+
}
88+
89+
match &matches[0] {
90+
CliId::Branch { name, .. } => {
91+
let branch = Category::LocalBranch.to_full_name(name.as_str())?;
92+
ensure_existing_local_branch(&repo, &branch)?;
93+
Ok(branch)
94+
}
95+
other => {
96+
let kind = match other {
97+
CliId::Branch { .. } => unreachable!("handled above"),
98+
CliId::Commit { .. } => "a commit",
99+
CliId::Uncommitted(..) => "an uncommitted file",
100+
CliId::PathPrefix { .. } => "a path",
101+
CliId::CommittedFile { .. } => "a committed file",
102+
CliId::Unassigned { .. } => "unassigned changes",
103+
CliId::Stack { .. } => "a stack",
104+
};
105+
Err(bad_input(format!("Invalid branch. '{}' is {kind}", target.0)).into())
106+
}
107+
}
108+
}
109+
110+
fn ensure_existing_local_branch(repo: &gix::Repository, branch: &FullName) -> CliResult<()> {
111+
if !branch.as_bstr().starts_with_str("refs/heads/") {
112+
return Err(bad_input(format!("Can only switch to local branches, got '{branch}'")).into());
113+
}
114+
if repo.try_find_reference(branch.as_ref())?.is_none() {
115+
return Err(bad_input(format!("Branch '{}' not found", branch.shorten())).into());
116+
}
117+
Ok(())
118+
}
119+
120+
fn looks_like_remote_branch(repo: &gix::Repository, target: &str) -> bool {
121+
repo.remote_names().iter().any(|remote| {
122+
target
123+
.as_bytes()
124+
.strip_prefix(remote.as_bstr().as_bytes())
125+
.is_some_and(|rest| rest.starts_with(b"/"))
126+
})
127+
}
128+
129+
fn current_head_short_name(ctx: &but_ctx::Context) -> CliResult<String> {
130+
let repo = ctx.repo.get()?;
131+
let head_name = repo
132+
.head_name()?
133+
.ok_or_else(|| anyhow::anyhow!("HEAD is detached after switching branches"))?;
134+
Ok(head_name.shorten().to_string())
135+
}

crates/but/src/lib.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,15 @@ async fn match_subcommand(
630630
};
631631
result.emit_metrics(metrics_ctx)
632632
}
633+
Subcommands::Switch {
634+
target,
635+
workspace,
636+
new,
637+
} => {
638+
let mut ctx = but_ctx::Context::discover(&args.current_dir)?;
639+
command::r#switch::handle(&mut ctx, out, target, workspace, new)
640+
.emit_metrics(metrics_ctx)
641+
}
633642
#[cfg(feature = "legacy")]
634643
Subcommands::Mcp => command::legacy::mcp::start(app_settings)
635644
.await

crates/but/src/utils/metrics.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ impl Subcommands {
115115
Subcommands::Unapply { .. } => BranchUnapply,
116116
#[cfg(feature = "legacy")]
117117
Subcommands::Apply { .. } => BranchApply,
118+
Subcommands::Switch { .. } => Switch,
118119
#[cfg(feature = "legacy")]
119120
Subcommands::Worktree(worktree::Platform { cmd: _ }) => Worktree,
120121
#[cfg(feature = "legacy")]

crates/but/tests/but/command/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ mod skill;
4545
mod squash;
4646
#[cfg(feature = "legacy")]
4747
mod status;
48+
mod r#switch;
4849
#[cfg(feature = "legacy")]
4950
mod teardown;
5051
#[cfg(feature = "legacy")]

0 commit comments

Comments
 (0)