|
| 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 | +} |
0 commit comments