use anyhow::{Context, Result};
use std::process::Command;
use crate::color;
use crate::commands::common::get_main_repo_root;
use crate::config;
use crate::domain::worktree::display_path;
use crate::hooks;
use crate::integrations;
use crate::integrations::gh::GhClient;
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>)> {
eprintln!(
"{}",
color::success(
color_mode,
&format!("Creating worktree for PR #{}: {}", pr.number, pr.title)
)
);
let is_fork = pr.is_cross_repository;
if is_fork {
eprintln!("{}", color::info(color_mode, "Fetching PR from 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()
.map(|o| o.status.success())
.unwrap_or(false);
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 {
eprintln!(
"{}",
color::info(
color_mode,
&format!("Fetching branch: {}", pr.head_ref_name)
)
);
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}");
}
let branch_exists = Command::new("git")
.args(["rev-parse", "--verify", &pr.head_ref_name])
.current_dir(repo_root)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
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(
number: u32,
start_point: Option<&str>,
repo_root: &std::path::Path,
color_mode: color::ColorMode,
) -> Result<(String, Option<String>)> {
let gh_client = integrations::gh::RealGhClient;
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}."
);
}
eprintln!(
"{}",
color::info(color_mode, &format!("Fetching GitHub #{number} info…"))
);
match gh_client.issue_info(number) {
Ok(issue) => {
if issue.is_pull_request {
match gh_client.pr_info(number) {
Ok(pr) => process_pr(&pr, number, repo_root, color_mode),
Err(_pr_err) => {
anyhow::bail!(
"#{number} is not a valid pull request.\n\
Please check the number and try again."
);
}
}
} else {
let branch_name = integrations::gh::build_issue_branch(number);
eprintln!(
"{}",
color::success(
color_mode,
&format!(
"Creating worktree for issue #{}: {}",
issue.number, issue.title
)
)
);
Ok((branch_name, start_point.map(String::from)))
}
}
Err(_issue_err) => {
match gh_client.pr_info(number) {
Ok(pr) => process_pr(&pr, number, repo_root, color_mode),
Err(_pr_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)
}
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 => {
resolve_github_ref(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 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()
.map(|o| o.status.success())
.unwrap_or(false);
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() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git worktree add failed: {stderr}");
}
if !config.hooks.create.run.is_empty()
|| !config.hooks.create.copy.is_empty()
|| !config.hooks.create.link.is_empty()
{
eprintln!("{}", color::info(color_mode, "Executing create hooks…"));
hooks::execute_hooks(&config.hooks.create, &worktree_path, &repo_root, color_mode)?;
}
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!("{}", display_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));
}
}