use anyhow::{Context, Result};
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use std::process::Command;
use std::time::Duration;
use crate::color;
use crate::commands::common::get_main_repo_root;
use crate::config;
use crate::domain::worktree::normalize_absolute_path;
use crate::hooks;
use crate::integrations;
use crate::integrations::tmux::TmuxLauncher;
fn process_pr(
pr: &integrations::gh::PrInfo,
number: u32,
repo_root: &std::path::Path,
color_mode: color::ColorMode,
) -> Result<(String, Option<String>)> {
let is_fork = pr.is_cross_repository;
if is_fork {
let fetch_output = Command::new("git")
.args(["fetch", "origin", &format!("refs/pull/{number}/head")])
.current_dir(repo_root)
.output()
.context("Failed to execute git fetch")?;
if !fetch_output.status.success() {
let stderr = String::from_utf8_lossy(&fetch_output.stderr);
anyhow::bail!("git fetch PR ref failed: {stderr}");
}
let branch_exists = Command::new("git")
.args(["rev-parse", "--verify", &pr.head_ref_name])
.current_dir(repo_root)
.output()
.is_ok_and(|o| o.status.success());
eprintln!(
"{}",
color::success(
color_mode,
&format!("Fetched PR #{}: {} (fork)", pr.number, pr.title)
)
);
if branch_exists {
let sanitized_ref = pr.head_ref_name.replace('/', "-");
let unique_branch = format!("pr-{number}-{sanitized_ref}");
eprintln!(
"{}",
color::warn(
color_mode,
&format!(
"Local branch '{}' already exists. Using '{}' instead.",
pr.head_ref_name, unique_branch
)
)
);
Ok((unique_branch, Some("FETCH_HEAD".to_string())))
} else {
Ok((pr.head_ref_name.clone(), Some("FETCH_HEAD".to_string())))
}
} else {
let fetch_output = Command::new("git")
.args(["fetch", "origin", &pr.head_ref_name])
.current_dir(repo_root)
.output()
.context("Failed to execute git fetch")?;
if !fetch_output.status.success() {
let stderr = String::from_utf8_lossy(&fetch_output.stderr);
anyhow::bail!("git fetch failed: {stderr}");
}
eprintln!(
"{}",
color::success(
color_mode,
&format!("Fetched PR #{}: {}", pr.number, pr.title)
)
);
let branch_exists = Command::new("git")
.args(["rev-parse", "--verify", &pr.head_ref_name])
.current_dir(repo_root)
.output()
.is_ok_and(|o| o.status.success());
if branch_exists {
Ok((pr.head_ref_name.clone(), None))
} else {
Ok((
pr.head_ref_name.clone(),
Some(format!("origin/{}", pr.head_ref_name)),
))
}
}
}
#[allow(clippy::type_complexity)]
fn resolve_github_ref(
gh_client: &impl integrations::gh::GhClient,
number: u32,
start_point: Option<&str>,
repo_root: &std::path::Path,
color_mode: color::ColorMode,
) -> Result<(String, Option<String>)> {
if !gh_client.is_available() {
anyhow::bail!(
"GitHub CLI (gh) is not installed or not available.\n\
Please install gh from https://cli.github.com/ to use GitHub integration.\n\
Alternatively, use a regular branch name instead of #{number}."
);
}
match gh_client.pr_info(number) {
Ok(pr) => process_pr(&pr, number, repo_root, color_mode),
Err(_pr_err) => match gh_client.issue_info(number) {
Ok(issue) => {
let branch_name = integrations::gh::build_issue_branch(number);
eprintln!(
"{}",
color::success(
color_mode,
&format!("Fetched issue #{}: {}", issue.number, issue.title)
)
);
Ok((branch_name, start_point.map(String::from)))
}
Err(_issue_err) => {
anyhow::bail!(
"#{number} is not a valid issue or pull request.\n\
Please check the number and try again."
);
}
},
}
}
const fn should_use_tmux(
behavior: config::TmuxBehavior,
tmux_flag: bool,
no_tmux_flag: bool,
) -> bool {
if no_tmux_flag {
return false;
}
if tmux_flag {
return true;
}
matches!(behavior, config::TmuxBehavior::Always)
}
#[allow(clippy::too_many_lines, clippy::missing_panics_doc)]
pub fn cmd_new(
branch: &str,
start_point: Option<&str>,
tmux: bool,
no_tmux: bool,
color_mode: color::ColorMode,
) -> Result<()> {
let repo_root = get_main_repo_root()?;
let config = config::Config::load_from_repo_root(&repo_root)?;
let branch_input = integrations::gh::BranchInput::parse(branch);
let (actual_branch, actual_start_point) = match branch_input {
integrations::gh::BranchInput::Github(number) if config.integrations.gh.enabled => {
let gh_client = integrations::gh::RealGhClient;
resolve_github_ref(&gh_client, number, start_point, &repo_root, color_mode)?
}
integrations::gh::BranchInput::Github(number) => {
eprintln!(
"{}",
color::warn(
color_mode,
&format!(
"GitHub integration is disabled. Treating '#{number}' as a literal branch name.\n\
To enable GitHub integration, set enabled = true in [integration.gh] in your global config."
)
)
);
(branch.to_string(), start_point.map(String::from))
}
integrations::gh::BranchInput::Plain(name) => (name, start_point.map(String::from)),
};
let branch = &actual_branch;
let start_point = actual_start_point.as_deref();
let use_tmux = should_use_tmux(config.integrations.tmux.behavior, tmux, no_tmux);
if use_tmux {
let launcher = integrations::tmux::RealTmuxLauncher;
launcher.detect()?;
}
let repo_name = repo_root
.file_name()
.and_then(|n| n.to_str())
.context("Failed to get repository name")?;
#[allow(clippy::literal_string_with_formatting_args)]
let path_template = config
.worktree
.dir
.replace("{repo}", repo_name)
.replace("{branch}", branch);
let worktree_path = if path_template.starts_with('/') {
std::path::PathBuf::from(&path_template)
} else {
repo_root.join(&path_template)
};
let mp = MultiProgress::new();
let is_tty = color_mode.should_colorize();
let header_pb = if is_tty {
let pb = mp.add(ProgressBar::new_spinner());
pb.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.cyan} {msg}")
.unwrap(),
);
pb.set_message(format!("Adding {branch}"));
pb.enable_steady_tick(Duration::from_millis(100));
Some(pb)
} else {
None
};
let mut cmd = Command::new("git");
if let Some(sp) = start_point {
cmd.args(["worktree", "add", "-b", branch])
.arg(&worktree_path)
.arg(sp);
} else {
let branch_exists = Command::new("git")
.args(["rev-parse", "--verify", branch])
.current_dir(&repo_root)
.output()
.is_ok_and(|o| o.status.success());
if branch_exists {
cmd.args(["worktree", "add"])
.arg(&worktree_path)
.arg(branch);
} else {
cmd.args(["worktree", "add", "-b", branch])
.arg(&worktree_path);
}
}
let output = cmd.output().context("Failed to execute git worktree add")?;
if !output.status.success() {
if let Some(pb) = header_pb {
pb.finish_and_clear();
}
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git worktree add failed: {stderr}");
}
if !is_tty {
eprintln!("{}", color::success(color_mode, format!("Added {branch}")));
}
if !config.hooks.create.run.is_empty()
|| !config.hooks.create.copy.is_empty()
|| !config.hooks.create.link.is_empty()
{
hooks::execute_hooks_lenient_with_mp(
&config.hooks.create,
&worktree_path,
&repo_root,
color_mode,
" ",
&mp,
);
}
if let Some(pb) = header_pb {
pb.set_style(ProgressStyle::with_template("{msg}").unwrap());
pb.finish_with_message(format!(
"{}",
color::success(color_mode, format!("Added {branch}"))
));
}
integrations::zoxide::add_to_zoxide_if_enabled(
&worktree_path,
config.integrations.zoxide.enabled,
)?;
if use_tmux {
let launcher = integrations::tmux::RealTmuxLauncher;
let result = match config.integrations.tmux.create.as_str() {
"pane" => launcher.create_pane(&worktree_path),
_ => launcher.create_window(&worktree_path, branch),
};
if let Err(e) = result {
eprintln!("Warning: tmux creation failed: {e}");
}
} else {
println!("{}", normalize_absolute_path(&worktree_path));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_should_use_tmux_no_tmux_flag_priority() {
use config::TmuxBehavior;
assert!(!should_use_tmux(TmuxBehavior::Always, true, true));
assert!(!should_use_tmux(TmuxBehavior::Always, false, true));
assert!(!should_use_tmux(TmuxBehavior::Auto, true, true));
assert!(!should_use_tmux(TmuxBehavior::Auto, false, true));
}
#[test]
fn test_should_use_tmux_tmux_flag_priority() {
use config::TmuxBehavior;
assert!(should_use_tmux(TmuxBehavior::Never, true, false));
assert!(should_use_tmux(TmuxBehavior::Auto, true, false));
assert!(should_use_tmux(TmuxBehavior::Always, true, false));
}
#[test]
fn test_should_use_tmux_behavior_auto() {
use config::TmuxBehavior;
assert!(!should_use_tmux(TmuxBehavior::Auto, false, false));
}
#[test]
fn test_should_use_tmux_behavior_always() {
use config::TmuxBehavior;
assert!(should_use_tmux(TmuxBehavior::Always, false, false));
}
#[test]
fn test_should_use_tmux_behavior_never() {
use config::TmuxBehavior;
assert!(!should_use_tmux(TmuxBehavior::Never, false, false));
}
#[test]
fn test_resolve_github_ref_issue_path() {
let mock = integrations::gh::MockGhClient::new()
.with_pr_error("not found")
.with_issue(integrations::gh::IssueInfo {
number: 33,
title: "Test issue".to_string(),
url: "https://github.com/owner/repo/issues/33".to_string(),
});
let result = resolve_github_ref(
&mock,
33,
None,
std::path::Path::new("/tmp"),
color::ColorMode::Never,
);
let (branch, start_point) = result.unwrap();
assert_eq!(branch, "issue-33");
assert!(start_point.is_none());
}
#[test]
fn test_resolve_github_ref_issue_path_with_start_point() {
let mock = integrations::gh::MockGhClient::new()
.with_pr_error("not found")
.with_issue(integrations::gh::IssueInfo {
number: 33,
title: "Test issue".to_string(),
url: "https://github.com/owner/repo/issues/33".to_string(),
});
let result = resolve_github_ref(
&mock,
33,
Some("develop"),
std::path::Path::new("/tmp"),
color::ColorMode::Never,
);
let (branch, start_point) = result.unwrap();
assert_eq!(branch, "issue-33");
assert_eq!(start_point.as_deref(), Some("develop"));
}
#[test]
fn test_resolve_github_ref_both_fail() {
let mock = integrations::gh::MockGhClient::new()
.with_pr_error("no pr")
.with_issue_error("no issue");
let result = resolve_github_ref(
&mock,
999,
None,
std::path::Path::new("/tmp"),
color::ColorMode::Never,
);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("not a valid issue or pull request"),
"unexpected error: {err}"
);
}
}