use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use anyhow::Context;
use color_print::cformat;
use normalize_path::NormalizePath;
use worktrunk::config::UserConfig;
use worktrunk::git::{GitError, Repository, ResolvedWorktree};
use worktrunk::path::{format_path_for_display, paths_match};
use worktrunk::styling::{
eprintln, format_toml, hint_message, info_message, success_message, warning_message,
};
use crate::output::prompt::{PromptResponse, prompt_yes_no_preview};
use super::types::OperationMode;
pub fn resolve_worktree_arg(
repo: &Repository,
name: &str,
config: &UserConfig,
context: OperationMode,
) -> anyhow::Result<ResolvedWorktree> {
match name {
"@" | "-" | "^" => {
return repo.resolve_worktree(name);
}
_ => {}
}
let branch = repo.resolve_worktree_name(name)?;
if let Some(path) = repo.worktree_for_branch(&branch)? {
return Ok(ResolvedWorktree::Worktree {
path,
branch: Some(branch),
});
}
if context == OperationMode::CreateOrSwitch {
let expected_path = compute_worktree_path(repo, name, config)?;
if let Some((_, occupant_branch)) = repo.worktree_at_path(&expected_path)? {
return Err(GitError::WorktreePathOccupied {
branch,
path: expected_path,
occupant: occupant_branch,
}
.into());
}
}
if context == OperationMode::Remove {
let candidate = Path::new(name);
let abs_path = if candidate.is_absolute() {
candidate.to_path_buf()
} else {
std::env::current_dir()
.context("Failed to determine current directory")?
.join(candidate)
};
if let Some((path, wt_branch)) = repo.worktree_at_path(&abs_path)? {
return Ok(ResolvedWorktree::Worktree {
path,
branch: wt_branch,
});
}
}
Ok(ResolvedWorktree::BranchOnly { branch })
}
pub fn compute_worktree_path(
repo: &Repository,
branch: &str,
config: &UserConfig,
) -> anyhow::Result<PathBuf> {
let repo_root = repo.repo_path()?;
let default_branch = repo.default_branch().unwrap_or_default();
let is_bare = repo.is_bare()?;
if !is_bare && branch == default_branch {
return Ok(repo_root.to_path_buf());
}
let repo_name = repo_root
.file_name()
.ok_or_else(|| {
anyhow::anyhow!(
"Repository path has no filename: {}",
format_path_for_display(repo_root)
)
})?
.to_str()
.ok_or_else(|| {
anyhow::anyhow!(
"Repository path contains invalid UTF-8: {}",
format_path_for_display(repo_root)
)
})?;
let project = repo.project_identifier().ok();
let expanded_path = config.format_path(repo_name, branch, repo, project.as_deref())?;
Ok(repo_root.join(expanded_path).normalize())
}
pub fn is_worktree_at_expected_path(
wt: &worktrunk::git::WorktreeInfo,
repo: &Repository,
config: &UserConfig,
) -> bool {
match &wt.branch {
Some(branch) => compute_worktree_path(repo, branch, config)
.map(|expected| paths_match(&wt.path, &expected))
.unwrap_or(false),
None => false,
}
}
pub fn path_mismatch(
repo: &Repository,
branch: &str,
actual_path: &std::path::Path,
config: &UserConfig,
) -> Option<PathBuf> {
compute_worktree_path(repo, branch, config)
.ok()
.filter(|expected| !paths_match(actual_path, expected))
}
pub fn worktree_display_name(
wt: &worktrunk::git::WorktreeInfo,
repo: &Repository,
config: &UserConfig,
) -> String {
let dir_name = wt.dir_name();
match &wt.branch {
Some(branch) => {
if is_worktree_at_expected_path(wt, repo, config) {
cformat!("<bold>{branch}</>")
} else {
cformat!("<bold>{dir_name}</> (on <bold>{branch}</>)")
}
}
None => cformat!("<bold>{dir_name}</> (detached)"),
}
}
pub(super) fn generate_backup_path(
path: &std::path::Path,
suffix: &str,
) -> anyhow::Result<PathBuf> {
let file_name = path.file_name().ok_or_else(|| {
anyhow::anyhow!(
"Cannot generate backup path for {}",
format_path_for_display(path)
)
})?;
if path.extension().is_none() {
Ok(path.with_file_name(format!("{}.bak.{suffix}", file_name.to_string_lossy())))
} else {
Ok(path.with_extension(format!(
"{}.bak.{suffix}",
path.extension()
.map(|e| e.to_string_lossy().to_string())
.unwrap_or_default()
)))
}
}
pub(super) fn compute_clobber_backup(
path: &Path,
branch: &str,
clobber: bool,
create: bool,
) -> anyhow::Result<Option<PathBuf>> {
if !path.exists() {
return Ok(None);
}
if clobber {
let timestamp = worktrunk::utils::epoch_now() as i64;
let datetime =
chrono::DateTime::from_timestamp(timestamp, 0).unwrap_or_else(chrono::Utc::now);
let suffix = datetime.format("%Y%m%d-%H%M%S").to_string();
let backup_path = generate_backup_path(path, &suffix)?;
if backup_path.exists() {
anyhow::bail!(
"Backup path already exists: {}",
worktrunk::path::format_path_for_display(&backup_path)
);
}
Ok(Some(backup_path))
} else {
Err(GitError::WorktreePathExists {
branch: branch.to_string(),
path: path.to_path_buf(),
create,
}
.into())
}
}
const BARE_REPO_WORKTREE_PATH: &str = "{{ repo_path }}/../{{ branch | sanitize }}";
fn template_references_repo_name(template: &str) -> bool {
worktrunk::config::template_references_var(template, "repo")
|| worktrunk::config::template_references_var(template, "main_worktree")
}
pub fn offer_bare_repo_worktree_path_fix(
repo: &Repository,
config: &mut UserConfig,
) -> anyhow::Result<bool> {
if !repo.is_bare()? {
return Ok(false);
}
let repo_path = repo.repo_path()?;
let repo_name = repo_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !repo_name.starts_with('.') {
return Ok(false);
}
if repo
.config_value("worktrunk.skip-bare-repo-prompt")
.unwrap_or(None)
.is_some()
{
return Ok(false);
}
let project_id = repo.project_identifier()?;
let template = config.worktree_path_for_project(&project_id);
if !template_references_repo_name(&template) {
return Ok(false);
}
let display_path = repo_path
.parent()
.map(|p| format_path_for_display(p).to_string())
.unwrap_or_else(|| format_path_for_display(repo_path).to_string());
let parent_name = repo_path
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or("project");
let example_bad = format!("{parent_name}/{repo_name}.feature-auth");
let example_good = format!("{parent_name}/feature-auth");
let config_path_display = worktrunk::config::config_path()
.map(|p| format_path_for_display(&p).to_string())
.unwrap_or_else(|| "~/.config/worktrunk/config.toml".to_string());
if !std::io::stdin().is_terminal() {
eprintln!(
"{}",
warning_message(cformat!(
"Bare repo at <bold>{parent_name}/{repo_name}</> — worktrees will be at <bold>{example_bad}</>"
))
);
eprintln!(
"{}",
hint_message(cformat!(
"To place worktrees at <underline>{example_good}</>, add to <underline>{config_path_display}</>:"
))
);
let config_snippet =
format!("[projects.\"{project_id}\"]\nworktree-path = \"{BARE_REPO_WORKTREE_PATH}\"");
eprintln!("{}", format_toml(&config_snippet));
return Ok(false);
}
eprintln!(
"{}",
warning_message(cformat!(
"Bare repo at <bold>{parent_name}/{repo_name}</> — worktrees will be at <bold>{example_bad}</>"
))
);
let config_path_for_preview = config_path_display.clone();
let project_id_for_preview = project_id.clone();
match prompt_yes_no_preview(
&cformat!("Configure worktree-path to place worktrees at <bold>{example_good}</>?"),
move || {
eprintln!(
"{}",
info_message(cformat!("Would add to <bold>{config_path_for_preview}</>:"))
);
let preview = format!(
"[projects.\"{project_id_for_preview}\"]\nworktree-path = \"{BARE_REPO_WORKTREE_PATH}\""
);
eprintln!("{}", format_toml(&preview));
eprintln!();
},
)? {
PromptResponse::Accepted => {
config.set_project_worktree_path(
&project_id,
BARE_REPO_WORKTREE_PATH.to_string(),
None,
)?;
print_accepted_message(&display_path, &config_path_display);
Ok(true)
}
PromptResponse::Declined => {
if let Err(e) = repo.set_config("worktrunk.skip-bare-repo-prompt", "true") {
log::warn!("Failed to save skip-bare-repo-prompt to git config: {e}");
}
Ok(false)
}
}
}
fn print_accepted_message(display_path: &str, config_path: &str) {
eprintln!(
"{}",
success_message(cformat!(
"Set <bold>worktree-path</> for <bold>{display_path}</>:"
))
);
let global_config = format!("worktree-path = \"{BARE_REPO_WORKTREE_PATH}\"");
eprintln!("{}", format_toml(&global_config));
eprintln!(
"{}",
hint_message(cformat!(
"To set globally, add to <underline>{config_path}</>"
))
);
eprintln!();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_backup_path_with_extension() {
let path = PathBuf::from("/tmp/repo.feature");
let backup = generate_backup_path(&path, "20250101-000000").unwrap();
assert_eq!(
backup,
PathBuf::from("/tmp/repo.feature.bak.20250101-000000")
);
let path = PathBuf::from("/tmp/file.txt");
let backup = generate_backup_path(&path, "20250101-000000").unwrap();
assert_eq!(backup, PathBuf::from("/tmp/file.txt.bak.20250101-000000"));
}
#[test]
fn test_generate_backup_path_without_extension() {
let path = PathBuf::from("/tmp/repo/feature");
let backup = generate_backup_path(&path, "20250101-000000").unwrap();
assert_eq!(
backup,
PathBuf::from("/tmp/repo/feature.bak.20250101-000000")
);
let path = PathBuf::from("/tmp/mydir");
let backup = generate_backup_path(&path, "20250101-000000").unwrap();
assert_eq!(backup, PathBuf::from("/tmp/mydir.bak.20250101-000000"));
}
#[test]
fn test_generate_backup_path_unusual_paths() {
let path = PathBuf::from("/");
assert!(generate_backup_path(&path, "20250101-000000").is_err());
let path = PathBuf::from("..");
assert!(generate_backup_path(&path, "20250101-000000").is_err());
}
#[test]
fn test_template_references_repo_name_default() {
assert!(template_references_repo_name(
"{{ repo_path }}/../{{ repo }}.{{ branch | sanitize }}"
));
}
#[test]
fn test_template_references_repo_name_with_filter() {
assert!(template_references_repo_name("{{ repo | sanitize }}"));
}
#[test]
fn test_template_references_repo_name_deprecated_alias() {
assert!(template_references_repo_name(
"{{ main_worktree }}.{{ branch }}"
));
}
#[test]
fn test_template_references_repo_name_not_repo_path() {
assert!(!template_references_repo_name(
"{{ repo_path }}/../{{ branch | sanitize }}"
));
}
#[test]
fn test_template_references_repo_name_no_repo() {
assert!(!template_references_repo_name("../{{ branch | sanitize }}"));
}
#[test]
fn test_template_references_repo_name_no_spaces() {
assert!(template_references_repo_name("{{repo}}.{{branch}}"));
}
#[test]
fn test_template_references_repo_name_no_braces() {
assert!(!template_references_repo_name("my-repo-path/{{ branch }}"));
}
#[test]
fn test_template_references_repo_name_substring_prefix() {
assert!(!template_references_repo_name("{{ myrepo }}"));
assert!(!template_references_repo_name("{{ norepo }}"));
}
}