#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
#![allow(clippy::multiple_crate_versions)]
mod args;
mod interactive;
mod output;
mod progress;
use std::env;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use clap::Parser;
use colored::Colorize;
use path_clean::PathClean;
use args::{Args, CleanArgs, RemoveArgs, SetupArgs};
use progress::ProgressManager;
use worktree_setup_config::{
BranchDeletePolicy, CreationMethod, LoadedConfig, PostSetupKeyword, PostSetupMode,
ResolvedProfile, discover_configs, load_config, load_global_config, resolve_profiles,
};
use worktree_setup_git::{
GitError, Repository, WorktreeCreateOptions, WorktreeInfo, create_worktree, delete_branch,
discover_repo, fetch_remote, get_current_branch, get_default_branch, get_local_branches,
get_main_worktree, get_recent_branches, get_remotes, get_repo_root,
get_unstaged_and_untracked_files, get_worktrees, prune_worktrees, remove_worktree,
};
use worktree_setup_operations::{
ApplyConfigOptions, OperationType, execute_operation, plan_operations_with_progress,
plan_unstaged_operations,
};
fn main() {
let args = Args::parse();
let verbose = match &args.command {
Some(args::Command::Setup(setup_args)) => setup_args.verbose,
Some(args::Command::Clean(clean_args)) => clean_args.verbose,
Some(args::Command::Remove(remove_args)) => remove_args.verbose,
None => args.verbose,
};
if verbose {
unsafe {
env::set_var("RUST_LOG", "debug");
}
}
pretty_env_logger::init();
let result = match args.command {
Some(args::Command::Setup(ref setup_args)) => run_setup(setup_args),
Some(args::Command::Clean(ref clean_args)) => run_clean(clean_args),
Some(args::Command::Remove(ref remove_args)) => run_remove(remove_args),
None => run_create(&args),
};
if let Err(e) = result {
output::print_error(&e.to_string());
std::process::exit(1);
}
}
fn discover_and_load_configs(
repo_root: &Path,
) -> Result<Vec<LoadedConfig>, Box<dyn std::error::Error>> {
let config_paths = discover_configs(repo_root)?;
let mut all_configs: Vec<LoadedConfig> = Vec::new();
if config_paths.is_empty() {
println!("No config files found.\n");
} else {
for path in config_paths {
match load_config(&path, repo_root) {
Ok(config) => all_configs.push(config),
Err(e) => {
output::print_warning(&format!("Failed to load {}: {}", path.display(), e));
}
}
}
if all_configs.is_empty() {
output::print_warning("All config files failed to load.\n");
} else {
let config_display: Vec<(String, String)> = all_configs
.iter()
.map(|c| (c.relative_path.clone(), c.config.description.clone()))
.collect();
output::print_config_list(&config_display);
}
}
Ok(all_configs)
}
fn select_configs(
all_configs: &[LoadedConfig],
config_patterns: &[String],
non_interactive: bool,
) -> Result<Vec<usize>, Box<dyn std::error::Error>> {
if all_configs.is_empty() {
return Ok(Vec::new());
}
if !config_patterns.is_empty() {
Ok(all_configs
.iter()
.enumerate()
.filter(|(_, c)| {
config_patterns.iter().any(|p| {
c.relative_path.contains(p) || c.config_path.to_string_lossy().contains(p)
})
})
.map(|(i, _)| i)
.collect())
} else if non_interactive {
Ok((0..all_configs.len()).collect())
} else {
Ok(interactive::select_configs(all_configs)?)
}
}
fn resolve_and_print_profile(
profile_names: &[String],
all_configs: &[LoadedConfig],
repo_root: &Path,
) -> Result<ResolvedProfile, Box<dyn std::error::Error>> {
let resolved = resolve_profiles(profile_names, all_configs, repo_root)?;
output::print_using_profile(&resolved.names);
let config_display: Vec<(String, String)> = resolved
.config_indices
.iter()
.map(|&i| {
(
all_configs[i].relative_path.clone(),
all_configs[i].config.description.clone(),
)
})
.collect();
output::print_profile_configs(&config_display);
Ok(resolved)
}
fn execute_file_operations(
selected_configs: &[&LoadedConfig],
main_worktree_path: &Path,
target_path: &Path,
copy_unstaged_override: Option<bool>,
overwrite_existing: bool,
show_progress: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let progress_mgr = ProgressManager::new(show_progress);
let options = ApplyConfigOptions {
copy_unstaged: copy_unstaged_override,
overwrite_existing,
};
let config_op_counts: Vec<usize> = selected_configs
.iter()
.map(|c| {
c.config.symlinks.len()
+ c.config.copy.len()
+ c.config.overwrite.len()
+ c.config.copy_glob.len()
+ c.config.templates.len()
})
.collect();
let total_ops: usize = config_op_counts.iter().sum();
let scanning_bar = progress_mgr.create_scanning_bar(total_ops as u64);
let mut all_operations = Vec::new();
let mut offset = 0usize;
for (config, &config_count) in selected_configs.iter().zip(&config_op_counts) {
let current_offset = offset;
let ops = plan_operations_with_progress(
config,
main_worktree_path,
target_path,
&options,
&|current, _total, path, file_count| {
scanning_bar.set_position((current_offset + current) as u64);
match file_count {
Some(n) => scanning_bar.set_message(format!("{path} ({n} files)")),
None => scanning_bar.set_message(path.to_string()),
}
},
)?;
offset += config_count;
all_operations.extend(ops);
}
scanning_bar.finish_and_clear();
let should_copy_unstaged = selected_configs
.iter()
.any(|c| copy_unstaged_override.unwrap_or(c.config.copy_unstaged));
if should_copy_unstaged {
println!("Checking for unstaged files...");
let repo = worktree_setup_git::open_repo(main_worktree_path)?;
let unstaged_files = get_unstaged_and_untracked_files(&repo)?;
if !unstaged_files.is_empty() {
println!(
"Found {} unstaged/untracked files to copy",
unstaged_files.len()
);
let unstaged_ops =
plan_unstaged_operations(&unstaged_files, main_worktree_path, target_path);
all_operations.extend(unstaged_ops);
}
}
for op in &all_operations {
if op.will_skip {
let reason = op.skip_reason.as_deref().unwrap_or("skipped");
progress_mgr.print_result(&op.display_path, reason, false);
continue;
}
let needs_progress_bar = op.is_directory && op.file_count > 1;
if needs_progress_bar {
let bar = progress_mgr.create_file_bar(&op.display_path, op.file_count);
let result = execute_operation(op, |completed, _total| {
bar.set_position(completed);
})?;
bar.finish_and_clear();
let result_str = format_result_string(result, op.operation_type);
progress_mgr.print_result_with_count(&op.display_path, &result_str, op.file_count);
} else {
let result = execute_operation(op, |_, _| {})?;
let result_str = format_result_string(result, op.operation_type);
progress_mgr.print_result(&op.display_path, &result_str, true);
}
}
progress_mgr.clear();
Ok(())
}
fn run_post_setup_commands(
commands: &[&str],
target_path: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
if commands.is_empty() {
return Ok(());
}
println!("Running post-setup commands:");
for cmd in commands {
output::print_command(cmd);
let mut child = Command::new("sh")
.args(["-c", cmd])
.current_dir(target_path)
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.spawn()?;
let status = child.wait()?;
if !status.success() {
output::print_warning(&format!("Command failed: {cmd}"));
}
}
println!();
Ok(())
}
fn collect_post_setup_commands<'a>(configs: &[&'a LoadedConfig]) -> Vec<&'a str> {
let mut unique_commands: Vec<&str> = Vec::new();
for config in configs {
for cmd in &config.config.post_setup {
let cmd_str = cmd.as_str();
if !unique_commands.contains(&cmd_str) {
unique_commands.push(cmd_str);
}
}
}
unique_commands
}
fn resolve_post_setup_commands<'a>(
no_install: bool,
profile: Option<&ResolvedProfile>,
available_commands: &[&'a str],
) -> Option<Vec<&'a str>> {
if no_install {
return Some(Vec::new());
}
let defaults = profile.map(|p| &p.defaults)?;
let post_setup = defaults.post_setup.as_ref()?;
match post_setup {
PostSetupMode::Keyword(PostSetupKeyword::None) => Some(Vec::new()),
PostSetupMode::Keyword(PostSetupKeyword::All) => {
let skip = &defaults.skip_post_setup;
if skip.is_empty() {
Some(available_commands.to_vec())
} else {
Some(
available_commands
.iter()
.filter(|cmd| !skip.iter().any(|s| s == **cmd))
.copied()
.collect(),
)
}
}
PostSetupMode::Commands(cmds) => {
Some(
available_commands
.iter()
.filter(|cmd| cmds.iter().any(|c| c == **cmd))
.copied()
.collect(),
)
}
}
}
fn resolve_overwrite(overwrite_flag: bool, profile: Option<&ResolvedProfile>) -> Option<bool> {
if overwrite_flag {
return Some(true);
}
profile.and_then(|p| p.defaults.overwrite_existing)
}
fn determine_setup_operations(
args: &SetupArgs,
resolved_profile: Option<&ResolvedProfile>,
is_secondary_worktree: bool,
unique_commands: &[&str],
) -> Result<(bool, bool, bool), Box<dyn std::error::Error>> {
let files_determined: Option<bool> = if args.no_files { Some(false) } else { None };
let overwrite_determined = resolve_overwrite(args.overwrite, resolved_profile);
let post_setup_resolved =
resolve_post_setup_commands(args.no_install, resolved_profile, unique_commands);
let post_setup_determined: Option<bool> =
post_setup_resolved.as_ref().map(|cmds| !cmds.is_empty());
if args.non_interactive {
let run_files = files_determined.unwrap_or(is_secondary_worktree);
let overwrite = overwrite_determined.unwrap_or(false);
let run_post_setup = post_setup_determined.unwrap_or(true);
return Ok((run_files, overwrite, run_post_setup));
}
let choices = interactive::prompt_setup_operations(
&interactive::SetupOperationInputs {
is_secondary_worktree,
files: files_determined,
overwrite: overwrite_determined,
post_setup: post_setup_determined,
},
unique_commands,
)?;
Ok((
choices.run_files,
choices.overwrite_existing,
choices.run_post_setup,
))
}
fn run_setup(args: &SetupArgs) -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let target_path = resolve_setup_target(&cwd, args.target_path.as_ref());
let repo = discover_repo(&target_path)?;
let repo_root = get_repo_root(&repo)?;
output::print_header("Worktree Setup");
output::print_repo_info(&repo_root.to_string_lossy());
println!();
let main_worktree = get_main_worktree(&repo)?;
let is_secondary_worktree = is_secondary(&target_path, &main_worktree.path);
if !is_secondary_worktree {
output::print_info("Not a secondary worktree. File operations will be skipped.");
println!();
}
let all_configs = discover_and_load_configs(&repo_root)?;
if all_configs.is_empty() {
output::print_warning("No configs found. Nothing to do.");
return Ok(());
}
let resolved_profile = if args.profile.is_empty() {
None
} else {
Some(resolve_and_print_profile(
&args.profile,
&all_configs,
&repo_root,
)?)
};
let selected_indices = select_configs_or_profile(
&all_configs,
args.non_interactive,
&args.configs,
resolved_profile.as_ref(),
)?;
let Some(selected_indices) = selected_indices else {
println!("No configs selected. Exiting.");
return Ok(());
};
let selected_configs: Vec<&LoadedConfig> =
selected_indices.iter().map(|&i| &all_configs[i]).collect();
let unique_commands = collect_post_setup_commands(&selected_configs);
let (run_files, overwrite_existing, run_post_setup) = determine_setup_operations(
args,
resolved_profile.as_ref(),
is_secondary_worktree,
&unique_commands,
)?;
if !run_files && !run_post_setup {
println!("Nothing selected. Exiting.");
return Ok(());
}
if run_files {
let copy_unstaged_override = args.copy_unstaged_override().or_else(|| {
resolved_profile
.as_ref()
.and_then(|p| p.defaults.copy_unstaged)
});
println!("\nApplying file operations to: {}", target_path.display());
println!("Source (main worktree): {}\n", main_worktree.path.display());
execute_file_operations(
&selected_configs,
&main_worktree.path,
&target_path,
copy_unstaged_override,
overwrite_existing,
args.should_show_progress(),
)?;
println!();
}
if run_post_setup {
let resolved_cmds = resolve_post_setup_commands(
args.no_install,
resolved_profile.as_ref(),
&unique_commands,
);
let cmds_to_run = resolved_cmds.as_deref().unwrap_or(&unique_commands);
run_post_setup_commands(cmds_to_run, &target_path)?;
}
output::print_success();
Ok(())
}
#[must_use]
fn resolve_setup_target(cwd: &Path, target: Option<&PathBuf>) -> PathBuf {
let target_path = target.map_or_else(
|| cwd.to_path_buf(),
|path| {
if path.is_absolute() {
path.clone()
} else {
cwd.join(path)
}
.clean()
},
);
if !target_path.exists() {
output::print_error(&format!(
"Target path does not exist: {}",
target_path.display()
));
std::process::exit(1);
}
target_path
}
fn is_glob_pattern(pattern: &str) -> bool {
pattern.contains('*') || pattern.contains('?') || pattern.contains('[')
}
fn path_size(path: &Path) -> u64 {
if path.is_symlink() {
return path.symlink_metadata().map_or(0, |m| file_disk_usage(&m));
}
if path.is_file() {
return path.metadata().map_or(0, |m| file_disk_usage(&m));
}
if !path.is_dir() {
return 0;
}
let mut total = 0u64;
for entry in walkdir::WalkDir::new(path).follow_links(false).min_depth(1) {
let Ok(entry) = entry else {
continue;
};
let ft = entry.file_type();
if ft.is_dir() || ft.is_symlink() {
continue;
}
let Ok(meta) = entry.metadata() else {
continue;
};
total += file_disk_usage(&meta);
}
total
}
fn file_disk_usage(meta: &std::fs::Metadata) -> u64 {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
meta.blocks() * 512
}
#[cfg(not(unix))]
{
meta.len()
}
}
fn resolve_clean_paths(
selected_configs: &[&LoadedConfig],
target_path: &Path,
target_canonical: &Path,
repo_root: &Path,
) -> Vec<(PathBuf, String)> {
let mut seen = std::collections::BTreeSet::new();
let mut results: Vec<(PathBuf, String)> = Vec::new();
for config in selected_configs {
let config_rel = config
.config_dir
.strip_prefix(repo_root)
.unwrap_or(&config.config_dir);
let target_config_dir = target_path.join(config_rel);
for pattern in &config.config.clean {
let (effective_pattern, base_dir) = pattern.strip_prefix('/').map_or_else(
|| (pattern.as_str(), target_config_dir.clone()),
|stripped| (stripped, target_path.to_path_buf()),
);
if is_glob_pattern(effective_pattern) {
resolve_clean_glob(
effective_pattern,
&base_dir,
target_canonical,
&mut seen,
&mut results,
);
} else {
resolve_clean_exact(
effective_pattern,
&base_dir,
target_canonical,
&mut seen,
&mut results,
);
}
}
}
results
.iter()
.filter(|(path, _)| {
!results
.iter()
.any(|(other, _)| other != path && path.starts_with(other))
})
.cloned()
.collect()
}
fn resolve_clean_exact(
pattern: &str,
base_dir: &Path,
target_canonical: &Path,
seen: &mut std::collections::BTreeSet<PathBuf>,
results: &mut Vec<(PathBuf, String)>,
) {
let candidate = base_dir.join(pattern);
if !candidate.exists() {
log::debug!(
"Clean path does not exist, skipping: {}",
candidate.display()
);
return;
}
let Ok(canonical) = candidate.canonicalize() else {
log::warn!("Could not canonicalize clean path: {}", candidate.display());
return;
};
if !canonical.starts_with(target_canonical) {
log::warn!("Clean path escapes target directory, skipping: {pattern}");
return;
}
if seen.insert(canonical.clone()) {
let relative = canonical.strip_prefix(target_canonical).map_or_else(
|_| candidate.to_string_lossy().to_string(),
|r| r.to_string_lossy().to_string(),
);
results.push((canonical, relative));
}
}
fn resolve_clean_glob(
pattern: &str,
base_dir: &Path,
target_canonical: &Path,
seen: &mut std::collections::BTreeSet<PathBuf>,
results: &mut Vec<(PathBuf, String)>,
) {
let glob = match globset::Glob::new(pattern) {
Ok(g) => g.compile_matcher(),
Err(e) => {
log::warn!("Invalid glob pattern '{pattern}': {e}");
return;
}
};
let mut it = walkdir::WalkDir::new(base_dir)
.follow_links(false)
.min_depth(1)
.into_iter();
loop {
let entry = match it.next() {
None => break,
Some(Err(e)) => {
log::debug!("Error during directory walk: {e}");
continue;
}
Some(Ok(entry)) => entry,
};
if entry.file_type().is_symlink() {
continue;
}
let path = entry.path();
let Ok(relative) = path.strip_prefix(base_dir) else {
continue;
};
if !glob.is_match(relative) {
continue;
}
let Ok(canonical) = path.canonicalize() else {
continue;
};
if !canonical.starts_with(target_canonical) {
log::warn!("Clean path escapes target directory, skipping: {pattern}");
continue;
}
if seen
.iter()
.any(|existing| canonical.starts_with(existing) && *existing != canonical)
{
continue;
}
if seen.insert(canonical.clone()) {
let display = canonical.strip_prefix(target_canonical).map_or_else(
|_| path.to_string_lossy().to_string(),
|r| r.to_string_lossy().to_string(),
);
results.push((canonical, display));
}
if entry.file_type().is_dir() {
it.skip_current_dir();
}
}
}
fn run_remove(args: &RemoveArgs) -> Result<(), Box<dyn std::error::Error>> {
if args.worktrees && args.target_path.is_some() {
return Err(
"--worktrees and a positional target path are mutually exclusive. \
Use --worktrees to select worktrees interactively, or provide a target path."
.into(),
);
}
let cwd = env::current_dir()?;
let repo = discover_repo(&cwd)?;
let repo_root = get_repo_root(&repo)?;
let global_config = load_global_config(Some(&repo_root))?;
let worktrees = get_worktrees(&repo)?;
output::print_header("Worktree Remove");
output::print_repo_info(&repo_root.to_string_lossy());
println!();
if args.worktrees {
return run_remove_interactive(args, &repo, &worktrees, &global_config);
}
if let Some(ref target) = args.target_path {
let target_path = if target.is_absolute() {
target.clone()
} else {
cwd.join(target).clean()
};
return run_remove_single(
args,
&repo,
&repo_root,
&worktrees,
&target_path,
&global_config,
);
}
let cwd_canonical = cwd.canonicalize().unwrap_or_else(|_| cwd.clone());
find_containing_linked_worktree(&cwd_canonical, &worktrees).map_or_else(
|| {
run_remove_interactive(args, &repo, &worktrees, &global_config)
},
|wt| {
run_remove_single(
args,
&repo,
&repo_root,
&worktrees,
&wt.path,
&global_config,
)
},
)
}
fn find_containing_linked_worktree<'a>(
path: &Path,
worktrees: &'a [WorktreeInfo],
) -> Option<&'a WorktreeInfo> {
worktrees.iter().find(|wt| {
if wt.is_main {
return false;
}
wt.path.canonicalize().map_or_else(
|_| path.starts_with(&wt.path),
|wt_canonical| path.starts_with(&wt_canonical),
)
})
}
fn worktree_has_changes(worktree_path: &Path) -> bool {
Repository::open(worktree_path).is_ok_and(|wt_repo| {
get_unstaged_and_untracked_files(&wt_repo).is_ok_and(|files| !files.is_empty())
})
}
fn handle_branch_deletion(
repo: &Repository,
branch: &str,
policy: BranchDeletePolicy,
non_interactive: bool,
force: bool,
dry_run: bool,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
let should_delete = match policy {
BranchDeletePolicy::Always => true,
BranchDeletePolicy::Never => return Ok(None),
BranchDeletePolicy::Ask => {
if non_interactive {
return Ok(None);
}
let prompt = format!("Delete local branch '{branch}'?");
dialoguer::Confirm::new()
.with_prompt(prompt)
.default(false)
.interact()?
}
};
if !should_delete {
return Ok(None);
}
if dry_run {
println!(" Would delete branch '{branch}'");
return Ok(Some(branch.to_string()));
}
match delete_branch(repo, branch, false) {
Ok(()) => Ok(Some(branch.to_string())),
Err(_) if force => {
delete_branch(repo, branch, true)?;
Ok(Some(branch.to_string()))
}
Err(e) => {
output::print_warning(&format!(
"Could not delete branch '{branch}': {e}. Use --force to force-delete."
));
Ok(None)
}
}
}
fn run_remove_single(
args: &RemoveArgs,
repo: &Repository,
repo_root: &Path,
worktrees: &[WorktreeInfo],
target_path: &Path,
global_config: &worktree_setup_config::GlobalConfig,
) -> Result<(), Box<dyn std::error::Error>> {
let target_canonical = target_path
.canonicalize()
.unwrap_or_else(|_| target_path.to_path_buf());
let wt = worktrees
.iter()
.find(|w| w.path.canonicalize().unwrap_or_else(|_| w.path.clone()) == target_canonical)
.ok_or_else(|| {
format!(
"No worktree found at '{}'. Use 'git worktree list' to see registered worktrees.",
target_path.display()
)
})?;
if wt.is_main {
return Err(format!(
"Cannot remove the main worktree at '{}'.",
target_path.display()
)
.into());
}
let has_changes = worktree_has_changes(&wt.path);
let display_info = vec![output::RemoveDisplayInfo {
branch: wt.branch.clone(),
path: wt.path.to_string_lossy().to_string(),
has_changes,
}];
output::print_remove_preview(&display_info);
if args.dry_run {
if let Some(ref branch) = wt.branch {
handle_branch_deletion(
repo,
branch,
global_config.remove.branch_delete,
args.non_interactive,
args.force,
true,
)?;
}
println!("\n{}", "Dry run — nothing was removed.".dimmed());
return Ok(());
}
if !args.force {
if args.non_interactive {
return Err(
"Remove requires confirmation. Use --force to skip, or --dry-run to preview."
.into(),
);
}
let confirm = dialoguer::Confirm::new()
.with_prompt("Proceed with removal?")
.default(false)
.interact()?;
if !confirm {
println!("Cancelled.");
return Ok(());
}
}
let cwd = env::current_dir()?;
let cwd_canonical = cwd.canonicalize().unwrap_or_else(|_| cwd.clone());
let cwd_inside = cwd_canonical.starts_with(&target_canonical);
let force_remove = args.force || has_changes;
match remove_worktree(repo, &wt.path, force_remove) {
Ok(()) => {
output::print_remove_summary(1, 0);
}
Err(e) => {
output::print_error(&format!("Failed to remove worktree: {e}"));
output::print_remove_summary(0, 1);
return Err(e.into());
}
}
let mut deleted_branches = Vec::new();
if let Some(ref branch) = wt.branch {
let repo = discover_repo(repo_root)?;
if let Ok(Some(deleted)) = handle_branch_deletion(
&repo,
branch,
global_config.remove.branch_delete,
args.non_interactive,
args.force,
false,
) {
deleted_branches.push(deleted);
}
}
output::print_branch_delete_summary(&deleted_branches);
if cwd_inside {
output::print_cwd_removed_note();
}
Ok(())
}
fn run_remove_interactive(
args: &RemoveArgs,
repo: &Repository,
worktrees: &[WorktreeInfo],
global_config: &worktree_setup_config::GlobalConfig,
) -> Result<(), Box<dyn std::error::Error>> {
let linked_count = worktrees.iter().filter(|w| !w.is_main).count();
if linked_count == 0 {
output::print_info("No linked worktrees to remove.");
return Ok(());
}
if args.non_interactive {
return Err("Interactive worktree selection requires a terminal. \
Provide a target path for non-interactive removal."
.into());
}
let (warning_tx, warning_rx) = mpsc::channel::<interactive::WarningResolution>();
let checks_done = Arc::new(AtomicBool::new(false));
let checks_done_clone = checks_done.clone();
let worktree_paths: Vec<(usize, std::path::PathBuf, bool)> = worktrees
.iter()
.enumerate()
.map(|(i, wt)| (i, wt.path.clone(), wt.is_main))
.collect();
std::thread::spawn(move || {
let handles: Vec<_> = worktree_paths
.into_iter()
.filter(|(_, _, is_main)| !is_main)
.map(|(index, path, _)| {
let tx = warning_tx.clone();
std::thread::spawn(move || {
let warning = if worktree_has_changes(&path) {
Some("has uncommitted changes".to_string())
} else {
None
};
let _ = tx.send(interactive::WarningResolution { index, warning });
})
})
.collect();
for handle in handles {
let _ = handle.join();
}
checks_done_clone.store(true, Ordering::Relaxed);
});
let (selection, resolved_warnings) =
interactive::select_worktrees_for_removal(worktrees, &warning_rx, &checks_done)?;
let Some(selected_indices) = selection else {
println!("Cancelled.");
return Ok(());
};
if selected_indices.is_empty() {
println!("No worktrees selected. Exiting.");
return Ok(());
}
let selected: Vec<&WorktreeInfo> = selected_indices.iter().map(|&i| &worktrees[i]).collect();
let display_infos: Vec<output::RemoveDisplayInfo> = selected_indices
.iter()
.map(|&i| {
let wt = &worktrees[i];
output::RemoveDisplayInfo {
branch: wt.branch.clone(),
path: wt.path.to_string_lossy().to_string(),
has_changes: resolved_warnings.get(i).and_then(Option::as_ref).is_some(),
}
})
.collect();
output::print_remove_preview(&display_infos);
if args.dry_run {
for wt in &selected {
if let Some(ref branch) = wt.branch {
handle_branch_deletion(
repo,
branch,
global_config.remove.branch_delete,
args.non_interactive,
args.force,
true,
)?;
}
}
println!("\n{}", "Dry run — nothing was removed.".dimmed());
return Ok(());
}
if !args.force {
let confirm = dialoguer::Confirm::new()
.with_prompt(format!(
"Remove {} worktree{}?",
selected.len(),
if selected.len() == 1 { "" } else { "s" }
))
.default(false)
.interact()?;
if !confirm {
println!("Cancelled.");
return Ok(());
}
}
execute_worktree_removals(
args,
repo,
worktrees,
&selected,
&display_infos,
global_config,
)
}
fn execute_worktree_removals(
args: &RemoveArgs,
repo: &Repository,
worktrees: &[WorktreeInfo],
selected: &[&WorktreeInfo],
display_infos: &[output::RemoveDisplayInfo],
global_config: &worktree_setup_config::GlobalConfig,
) -> Result<(), Box<dyn std::error::Error>> {
let mut removed = 0usize;
let mut failed = 0usize;
let mut deleted_branches = Vec::new();
let cwd = env::current_dir()?;
let cwd_canonical = cwd.canonicalize().unwrap_or_else(|_| cwd.clone());
let mut cwd_was_removed = false;
for (idx, wt) in selected.iter().enumerate() {
let has_changes = display_infos[idx].has_changes;
let force_remove = args.force || has_changes;
let wt_canonical = wt.path.canonicalize().unwrap_or_else(|_| wt.path.clone());
if cwd_canonical.starts_with(&wt_canonical) {
cwd_was_removed = true;
}
match remove_worktree(repo, &wt.path, force_remove) {
Ok(()) => {
removed += 1;
}
Err(e) => {
output::print_warning(&format!(
"Failed to remove worktree at '{}': {e}",
wt.path.display()
));
failed += 1;
continue; }
}
if let Some(ref branch) = wt.branch {
if let Ok(fresh_repo) = discover_repo(&worktrees[0].path)
&& let Ok(Some(deleted)) = handle_branch_deletion(
&fresh_repo,
branch,
global_config.remove.branch_delete,
args.non_interactive,
args.force,
false,
)
{
deleted_branches.push(deleted);
}
}
}
output::print_remove_summary(removed, failed);
output::print_branch_delete_summary(&deleted_branches);
if cwd_was_removed {
output::print_cwd_removed_note();
}
Ok(())
}
fn run_clean(args: &CleanArgs) -> Result<(), Box<dyn std::error::Error>> {
if args.worktrees && args.target_path.is_some() {
return Err(
"--worktrees and a positional target path are mutually exclusive. \
Use --worktrees to select worktrees interactively, or provide a target path."
.into(),
);
}
if args.worktrees {
run_clean_multi_worktree(args)
} else {
run_clean_single(args)
}
}
struct WorktreeCleanGroup {
label: String,
resolved: Vec<(PathBuf, String)>,
items: Vec<output::CleanItem>,
}
fn run_clean_multi_worktree(args: &CleanArgs) -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let repo = discover_repo(&cwd)?;
let repo_root = get_repo_root(&repo)?;
output::print_header("Worktree Clean (multi)");
output::print_repo_info(&repo_root.to_string_lossy());
println!();
let worktrees = get_worktrees(&repo)?;
if worktrees.len() < 2 {
output::print_info("Only one worktree found. Use clean without --worktrees instead.");
return Ok(());
}
if args.non_interactive {
return Err(
"--worktrees requires interactive mode for worktree selection. \
Remove --non-interactive to use this feature."
.into(),
);
}
let selected_configs = discover_and_select_clean_configs(args, &repo_root)?;
if selected_configs.is_empty() {
return Ok(());
}
let has_clean_paths = selected_configs.iter().any(|c| !c.config.clean.is_empty());
if !has_clean_paths {
output::print_info("No clean paths defined in selected configs.");
return Ok(());
}
let (result_tx, result_rx) = mpsc::channel::<interactive::WorktreeResolution>();
let done = Arc::new(AtomicBool::new(false));
let done_for_joiner = done.clone();
let configs_arc: Arc<Vec<LoadedConfig>> = Arc::new(selected_configs);
let repo_root_arc: Arc<PathBuf> = Arc::new(repo_root.clone());
let mut bg_handles = Vec::new();
for (idx, wt) in worktrees.iter().enumerate() {
let tx = result_tx.clone();
let configs = configs_arc.clone();
let root = repo_root_arc.clone();
let wt = wt.clone();
bg_handles.push(std::thread::spawn(move || {
let configs_refs: Vec<&LoadedConfig> = configs.iter().collect();
let resolution = resolve_single_worktree(idx, &wt, &configs_refs, &root);
let _ = tx.send(resolution);
}));
}
drop(result_tx);
let joiner_handle = std::thread::spawn(move || {
for handle in bg_handles {
let _ = handle.join();
}
done_for_joiner.store(true, Ordering::Relaxed);
});
let (selected_indices, mut cached_resolutions) =
interactive::select_worktrees_with_sizes(&worktrees, &result_rx, &done)?;
let Some(selected_indices) = selected_indices else {
println!("Cancelled.");
return Ok(());
};
if selected_indices.is_empty() {
println!("No worktrees selected. Exiting.");
return Ok(());
}
let _ = joiner_handle.join();
while let Ok(res) = result_rx.try_recv() {
cached_resolutions.push(res);
}
let groups = build_groups_from_cache(&selected_indices, &cached_resolutions, &worktrees);
let groups = if groups.len() == selected_indices.len() {
groups
} else {
let configs_refs: Vec<&LoadedConfig> = configs_arc.iter().collect();
let selected_wts: Vec<_> = selected_indices.iter().map(|&i| &worktrees[i]).collect();
resolve_multi_worktree_clean(&selected_wts, &configs_refs, &repo_root)
};
let total_items: usize = groups.iter().map(|g| g.items.len()).sum();
if total_items == 0 {
output::print_info(
"All clean paths are already clean across selected worktrees (nothing to delete).",
);
return Ok(());
}
let display_groups: Vec<(String, Vec<output::CleanItem>)> = groups
.iter()
.map(|g| (g.label.clone(), g.items.clone()))
.collect();
println!();
output::print_multi_worktree_clean_preview(&display_groups);
if args.dry_run {
println!("\n{}", "Dry run — nothing was deleted.".dimmed());
return Ok(());
}
confirm_and_delete_multi(args.force, args.non_interactive, &groups)
}
fn build_groups_from_cache(
selected_indices: &[usize],
resolutions: &[interactive::WorktreeResolution],
worktrees: &[worktree_setup_git::WorktreeInfo],
) -> Vec<WorktreeCleanGroup> {
let mut groups = Vec::new();
for &idx in selected_indices {
if let Some(res) = resolutions.iter().find(|r| r.index == idx) {
groups.push(WorktreeCleanGroup {
label: worktree_clean_label(&worktrees[idx]),
resolved: res.resolved.clone(),
items: res.items.clone(),
});
}
}
groups
}
fn resolve_single_worktree(
index: usize,
wt: &worktree_setup_git::WorktreeInfo,
configs: &[&LoadedConfig],
repo_root: &Path,
) -> interactive::WorktreeResolution {
let target_path = &wt.path;
let Ok(target_canonical) = target_path.canonicalize() else {
return interactive::WorktreeResolution {
index,
resolved: Vec::new(),
items: Vec::new(),
summary: "inaccessible".to_string(),
};
};
let resolved = resolve_clean_paths(configs, target_path, &target_canonical, repo_root);
let items: Vec<output::CleanItem> = resolved
.iter()
.map(|(abs_path, rel_path)| {
let is_dir = abs_path.is_dir();
let size = path_size(abs_path);
output::CleanItem {
relative_path: rel_path.clone(),
is_dir,
size,
}
})
.collect();
let summary = if items.is_empty() {
"nothing to clean".to_string()
} else {
let total_size: u64 = items.iter().map(|i| i.size).sum();
format!(
"{} item{}, {}",
items.len(),
if items.len() == 1 { "" } else { "s" },
output::format_size(total_size)
)
};
interactive::WorktreeResolution {
index,
resolved,
items,
summary,
}
}
fn discover_and_select_clean_configs(
args: &CleanArgs,
repo_root: &Path,
) -> Result<Vec<LoadedConfig>, Box<dyn std::error::Error>> {
let all_configs = discover_and_load_configs(repo_root)?;
if all_configs.is_empty() {
output::print_warning("No configs found. Nothing to clean.");
return Ok(Vec::new());
}
let resolved_profile = if args.profile.is_empty() {
None
} else {
Some(resolve_and_print_profile(
&args.profile,
&all_configs,
repo_root,
)?)
};
let selected_indices = select_configs_or_profile(
&all_configs,
args.non_interactive,
&args.configs,
resolved_profile.as_ref(),
)?;
let Some(selected_indices) = selected_indices else {
return Ok(Vec::new());
};
Ok(selected_indices
.iter()
.map(|&i| all_configs[i].clone())
.collect())
}
fn resolve_multi_worktree_clean(
worktrees: &[&worktree_setup_git::WorktreeInfo],
selected_configs: &[&LoadedConfig],
repo_root: &Path,
) -> Vec<WorktreeCleanGroup> {
let mut groups = Vec::new();
for wt in worktrees {
let target_path = &wt.path;
let target_canonical = match target_path.canonicalize() {
Ok(c) => c,
Err(e) => {
output::print_warning(&format!(
"Could not access worktree '{}': {}",
target_path.display(),
e
));
continue;
}
};
let resolved =
resolve_clean_paths(selected_configs, target_path, &target_canonical, repo_root);
let items: Vec<output::CleanItem> = resolved
.iter()
.map(|(abs_path, rel_path)| {
let is_dir = abs_path.is_dir();
let size = path_size(abs_path);
output::CleanItem {
relative_path: rel_path.clone(),
is_dir,
size,
}
})
.collect();
let label = worktree_clean_label(wt);
groups.push(WorktreeCleanGroup {
label,
resolved,
items,
});
}
groups
}
fn worktree_clean_label(wt: &worktree_setup_git::WorktreeInfo) -> String {
let suffix = if wt.is_main { " [main]" } else { "" };
wt.branch.as_ref().map_or_else(
|| {
wt.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string()
+ suffix
},
|branch| format!("{branch}{suffix}"),
)
}
fn confirm_and_delete_multi(
force: bool,
non_interactive: bool,
groups: &[WorktreeCleanGroup],
) -> Result<(), Box<dyn std::error::Error>> {
if !force {
if non_interactive {
return Err(
"Clean requires confirmation. Use --force to skip, or --dry-run to preview.".into(),
);
}
let confirm = dialoguer::Confirm::new()
.with_prompt("Proceed with deletion across all selected worktrees?")
.default(false)
.interact()?;
if !confirm {
println!("Cancelled.");
return Ok(());
}
}
let mut deleted_count = 0usize;
let mut total_size = 0u64;
let mut worktrees_cleaned = 0usize;
for group in groups {
if group.items.is_empty() {
continue;
}
let mut worktree_had_deletion = false;
for (idx, (abs_path, _)) in group.resolved.iter().enumerate() {
let item = &group.items[idx];
let result = if abs_path.is_dir() {
std::fs::remove_dir_all(abs_path)
} else {
std::fs::remove_file(abs_path)
};
match result {
Ok(()) => {
deleted_count += 1;
total_size += item.size;
worktree_had_deletion = true;
}
Err(e) => {
output::print_warning(&format!(
"Failed to delete '{}': {}",
item.relative_path, e
));
}
}
}
if worktree_had_deletion {
worktrees_cleaned += 1;
}
}
println!();
output::print_multi_worktree_clean_summary(deleted_count, total_size, worktrees_cleaned);
Ok(())
}
fn run_clean_single(args: &CleanArgs) -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let target_path = resolve_setup_target(&cwd, args.target_path.as_ref());
let repo = discover_repo(&target_path)?;
let repo_root = get_repo_root(&repo)?;
output::print_header("Worktree Clean");
output::print_repo_info(&repo_root.to_string_lossy());
println!();
let all_configs = discover_and_load_configs(&repo_root)?;
if all_configs.is_empty() {
output::print_warning("No configs found. Nothing to clean.");
return Ok(());
}
let resolved_profile = if args.profile.is_empty() {
None
} else {
Some(resolve_and_print_profile(
&args.profile,
&all_configs,
&repo_root,
)?)
};
let selected_indices = select_configs_or_profile(
&all_configs,
args.non_interactive,
&args.configs,
resolved_profile.as_ref(),
)?;
let Some(selected_indices) = selected_indices else {
println!("No configs selected. Exiting.");
return Ok(());
};
let selected_configs: Vec<&LoadedConfig> =
selected_indices.iter().map(|&i| &all_configs[i]).collect();
let has_clean_paths = selected_configs.iter().any(|c| !c.config.clean.is_empty());
if !has_clean_paths {
output::print_info("No clean paths defined in selected configs.");
return Ok(());
}
let target_canonical = target_path.canonicalize().map_err(|e| {
format!(
"Could not canonicalize target path '{}': {}",
target_path.display(),
e
)
})?;
let resolved = resolve_clean_paths(
&selected_configs,
&target_path,
&target_canonical,
&repo_root,
);
if resolved.is_empty() {
output::print_info("All clean paths are already clean (nothing exists to delete).");
return Ok(());
}
let items: Vec<output::CleanItem> = resolved
.iter()
.map(|(abs_path, rel_path)| {
let is_dir = abs_path.is_dir();
let size = path_size(abs_path);
output::CleanItem {
relative_path: rel_path.clone(),
is_dir,
size,
}
})
.collect();
println!();
output::print_clean_preview(&items);
if args.dry_run {
println!("\n{}", "Dry run — nothing was deleted.".dimmed());
return Ok(());
}
confirm_and_delete(args.force, args.non_interactive, &resolved, &items)
}
fn confirm_and_delete(
force: bool,
non_interactive: bool,
resolved: &[(PathBuf, String)],
items: &[output::CleanItem],
) -> Result<(), Box<dyn std::error::Error>> {
if !force {
if non_interactive {
return Err(
"Clean requires confirmation. Use --force to skip, or --dry-run to preview.".into(),
);
}
let confirm = dialoguer::Confirm::new()
.with_prompt("Proceed with deletion?")
.default(false)
.interact()?;
if !confirm {
println!("Cancelled.");
return Ok(());
}
}
let mut deleted_count = 0usize;
let mut total_size = 0u64;
for (idx, (abs_path, _rel_path)) in resolved.iter().enumerate() {
let item = &items[idx];
let result = if abs_path.is_dir() {
std::fs::remove_dir_all(abs_path)
} else {
std::fs::remove_file(abs_path)
};
match result {
Ok(()) => {
deleted_count += 1;
total_size += item.size;
}
Err(e) => {
output::print_warning(&format!("Failed to delete '{}': {}", item.relative_path, e));
}
}
}
println!();
output::print_clean_summary(deleted_count, total_size);
Ok(())
}
fn is_secondary(target: &Path, main_worktree_path: &Path) -> bool {
let target_canonical = target
.canonicalize()
.unwrap_or_else(|_| target.to_path_buf());
let main_canonical = main_worktree_path
.canonicalize()
.unwrap_or_else(|_| main_worktree_path.to_path_buf());
target_canonical != main_canonical
}
fn select_configs_or_profile(
all_configs: &[LoadedConfig],
non_interactive: bool,
config_patterns: &[String],
profile: Option<&ResolvedProfile>,
) -> Result<Option<Vec<usize>>, Box<dyn std::error::Error>> {
if let Some(p) = profile {
if p.config_indices.is_empty() {
output::print_warning("Profile matched no configs.");
return Ok(None);
}
return Ok(Some(p.config_indices.clone()));
}
let indices = select_configs(all_configs, config_patterns, non_interactive)?;
if indices.is_empty() {
return Ok(None);
}
Ok(Some(indices))
}
fn collect_profile_display_info(all_configs: &[LoadedConfig]) -> Vec<(String, String, usize)> {
use std::collections::BTreeMap;
let mut profiles: BTreeMap<String, (String, usize)> = BTreeMap::new();
for config in all_configs {
for (name, def) in &config.config.profiles {
let entry = profiles.entry(name.clone()).or_default();
if !def.description.is_empty() {
entry.0.clone_from(&def.description);
}
entry.1 += 1;
}
}
profiles
.into_iter()
.map(|(name, (desc, count))| (name, desc, count))
.collect()
}
fn handle_worktree_creation(
args: &Args,
repo: &worktree_setup_git::Repository,
target_path: &Path,
profile: Option<&ResolvedProfile>,
) -> Result<(), Box<dyn std::error::Error>> {
let profile_defaults = profile.map(|p| &p.defaults);
let worktree_name = target_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("worktree");
let effective_remote = args
.remote
.as_deref()
.or_else(|| profile_defaults.and_then(|d| d.remote.as_deref()));
let creation_method = profile_defaults.and_then(|d| d.creation_method.as_ref());
let auto_create = profile_defaults
.and_then(|d| d.auto_create)
.unwrap_or(false);
let profile_base_branch = profile_defaults.and_then(|d| d.base_branch.as_deref());
let profile_new_branch = profile_defaults.and_then(|d| d.new_branch).unwrap_or(false);
let is_remote =
creation_method == Some(&CreationMethod::Remote) || args.remote_branch.is_some();
let inferred_branch = if is_remote && !args.no_infer_branch {
Some(worktree_name)
} else {
None
};
let hints = interactive::CreationProfileHints {
auto_create,
creation_method,
base_branch: profile_base_branch,
new_branch: profile_new_branch,
remote_override: effective_remote,
inferred_branch,
};
let options = if args.non_interactive {
handle_creation_non_interactive(args, repo, target_path, &hints, worktree_name)?
} else {
let result = handle_creation_interactive(args, repo, target_path, &hints)?;
let Some(options) = result else {
return Ok(());
};
println!("\nCreating worktree at {}...", target_path.display());
options
};
create_worktree_with_recovery(repo, target_path, &options, args.non_interactive)
}
fn handle_creation_non_interactive(
args: &Args,
repo: &worktree_setup_git::Repository,
target_path: &Path,
hints: &interactive::CreationProfileHints<'_>,
worktree_name: &str,
) -> Result<WorktreeCreateOptions, Box<dyn std::error::Error>> {
let is_remote =
hints.creation_method == Some(&CreationMethod::Remote) || args.remote_branch.is_some();
let branch = if let Some(ref remote_branch) = args.remote_branch {
let remote = resolve_remote_non_interactive(repo, hints.remote_override)?;
println!("Fetching from {remote}...");
fetch_remote(repo, &remote)?;
Some(remote_branch.clone())
} else if hints.creation_method == Some(&CreationMethod::Remote) && !args.no_infer_branch {
let remote = resolve_remote_non_interactive(repo, hints.remote_override)?;
println!("Fetching from {remote}...");
fetch_remote(repo, &remote)?;
println!("Inferred remote branch: {remote}/{worktree_name}");
Some(worktree_name.to_string())
} else if hints.creation_method == Some(&CreationMethod::Remote) && args.no_infer_branch {
return Err(
"Profile sets creationMethod = \"remote\" but --no-infer-branch is set. \
Use --remote-branch <name> to specify the branch explicitly."
.into(),
);
} else if hints.creation_method == Some(&CreationMethod::Current) {
get_current_branch(repo)?
} else if hints.creation_method == Some(&CreationMethod::Detach) {
None
} else {
args.branch
.clone()
.or_else(|| hints.base_branch.map(String::from))
};
let new_branch = if is_remote
|| hints.creation_method == Some(&CreationMethod::Detach)
|| hints.creation_method == Some(&CreationMethod::Current)
{
None
} else {
args.new_branch.clone().or_else(|| {
if hints.new_branch {
Some(worktree_name.to_string())
} else {
None
}
})
};
let detach = hints.creation_method == Some(&CreationMethod::Detach);
println!("Creating worktree at {}...", target_path.display());
Ok(WorktreeCreateOptions {
branch,
new_branch,
force: args.force,
detach,
})
}
fn handle_creation_interactive(
_args: &Args,
repo: &worktree_setup_git::Repository,
target_path: &Path,
hints: &interactive::CreationProfileHints<'_>,
) -> Result<Option<WorktreeCreateOptions>, Box<dyn std::error::Error>> {
let current_branch = get_current_branch(repo)?;
let branches = get_local_branches(repo)?;
let default_branch = get_default_branch(repo);
let recent_branches = get_recent_branches(repo, 5);
Ok(interactive::prompt_worktree_create(
repo,
target_path,
current_branch.as_deref(),
&branches,
default_branch.as_deref(),
&recent_branches,
hints,
)?)
}
fn create_worktree_with_recovery(
repo: &worktree_setup_git::Repository,
path: &Path,
options: &WorktreeCreateOptions,
non_interactive: bool,
) -> Result<(), Box<dyn std::error::Error>> {
match create_worktree(repo, path, options) {
Ok(()) => Ok(()),
Err(ref e) if is_stale_worktree_error(e) => {
if non_interactive {
return Err(format!(
"Path '{}' is registered as a stale worktree. \
Use --force to override, or run 'git worktree prune' first.",
path.display()
)
.into());
}
match interactive::prompt_stale_worktree_recovery()? {
interactive::StaleWorktreeAction::Prune => {
println!("Pruning stale worktrees...");
prune_worktrees(repo)?;
println!("Retrying worktree creation...");
create_worktree(repo, path, options)?;
Ok(())
}
interactive::StaleWorktreeAction::Force => {
println!("Force creating worktree...");
let mut forced = options.clone();
forced.force = true;
create_worktree(repo, path, &forced)?;
Ok(())
}
interactive::StaleWorktreeAction::Cancel => {
Err("Worktree creation cancelled.".into())
}
}
}
Err(e) => Err(e.into()),
}
}
fn is_stale_worktree_error(err: &GitError) -> bool {
match err {
GitError::WorktreeCreateError { source, .. } => source
.message()
.contains("is a missing but already registered worktree"),
_ => false,
}
}
fn resolve_remote_non_interactive(
repo: &worktree_setup_git::Repository,
override_name: Option<&str>,
) -> Result<String, Box<dyn std::error::Error>> {
if let Some(name) = override_name {
return Ok(name.to_string());
}
let remotes = get_remotes(repo)?;
match remotes.len() {
0 => Err("No remotes configured in this repository".into()),
1 => Ok(remotes.into_iter().next().unwrap_or_default()),
_ => Err(format!(
"Multiple remotes found ({}). Use --remote to specify which one.",
remotes.join(", ")
)
.into()),
}
}
fn run_create(args: &Args) -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let repo = discover_repo(&cwd)?;
let repo_root = get_repo_root(&repo)?;
output::print_header("Worktree Setup");
output::print_repo_info(&repo_root.to_string_lossy());
println!();
let all_configs = discover_and_load_configs(&repo_root)?;
if args.list {
let profile_display = collect_profile_display_info(&all_configs);
if !profile_display.is_empty() {
output::print_profile_list(&profile_display);
}
return Ok(());
}
let resolved_profile = if args.profile.is_empty() {
None
} else {
Some(resolve_and_print_profile(
&args.profile,
&all_configs,
&repo_root,
)?)
};
let selected_configs: Vec<&LoadedConfig> = if all_configs.is_empty() {
Vec::new()
} else if let Some(indices) = select_configs_or_profile(
&all_configs,
args.non_interactive,
&args.configs,
resolved_profile.as_ref(),
)? {
indices.iter().map(|&i| &all_configs[i]).collect()
} else {
println!("No configs selected. Exiting.");
return Ok(());
};
let target_path = if let Some(ref path) = args.target_path {
PathBuf::from(path)
} else if args.non_interactive {
output::print_error("Target path is required in non-interactive mode.");
std::process::exit(1);
} else {
interactive::prompt_worktree_path()?
};
let target_path = if target_path.is_absolute() {
target_path
} else {
cwd.join(&target_path)
}
.clean();
let main_worktree = get_main_worktree(&repo)?;
if target_path == main_worktree.path {
output::print_error(
"Cannot set up the main worktree. This tool is for secondary worktrees.",
);
std::process::exit(1);
}
if !target_path.exists() {
handle_worktree_creation(args, &repo, &target_path, resolved_profile.as_ref())?;
}
if !target_path.exists() {
output::print_error(&format!(
"Target path does not exist: {}",
target_path.display()
));
std::process::exit(1);
}
if !selected_configs.is_empty() {
apply_create_operations(
args,
&selected_configs,
resolved_profile.as_ref(),
&main_worktree.path,
&target_path,
)?;
}
output::print_success();
Ok(())
}
fn apply_create_operations(
args: &Args,
selected_configs: &[&LoadedConfig],
resolved_profile: Option<&ResolvedProfile>,
main_worktree_path: &Path,
target_path: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
println!("\nSetting up worktree: {}", target_path.display());
println!("Main worktree: {}\n", main_worktree_path.display());
let copy_unstaged_override = args
.copy_unstaged_override()
.or_else(|| resolved_profile.and_then(|p| p.defaults.copy_unstaged));
execute_file_operations(
selected_configs,
main_worktree_path,
target_path,
copy_unstaged_override,
false, args.should_show_progress(),
)?;
println!();
let unique_commands = collect_post_setup_commands(selected_configs);
if unique_commands.is_empty() {
return Ok(());
}
let resolved_cmds =
resolve_post_setup_commands(args.no_install, resolved_profile, &unique_commands);
match resolved_cmds {
Some(cmds) => {
if !cmds.is_empty() {
run_post_setup_commands(&cmds, target_path)?;
}
}
None => {
if args.non_interactive {
run_post_setup_commands(&unique_commands, target_path)?;
} else {
let should_run = interactive::prompt_run_install(true)?;
if should_run {
run_post_setup_commands(&unique_commands, target_path)?;
}
}
}
}
Ok(())
}
fn format_result_string(
result: worktree_setup_operations::OperationResult,
op_type: OperationType,
) -> String {
use worktree_setup_operations::OperationResult;
match (result, op_type) {
(OperationResult::Created, OperationType::Symlink) => "symlink".to_string(),
(OperationResult::Created, OperationType::Template) => "created".to_string(),
(
OperationResult::Created,
OperationType::Copy
| OperationType::CopyGlob
| OperationType::Unstaged
| OperationType::Overwrite,
) => "copied".to_string(),
(OperationResult::Overwritten, _) => "overwritten".to_string(),
(OperationResult::Exists, _) => "exists".to_string(),
(OperationResult::Skipped, _) => "skipped".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use worktree_setup_config::{
PostSetupKeyword, PostSetupMode, ProfileDefaults, ResolvedProfile,
};
fn make_profile(defaults: ProfileDefaults) -> ResolvedProfile {
ResolvedProfile {
names: vec!["test".to_string()],
defaults,
..Default::default()
}
}
#[test]
fn test_resolve_post_setup_no_install_flag_wins() {
let profile = make_profile(ProfileDefaults {
post_setup: Some(PostSetupMode::Keyword(PostSetupKeyword::All)),
..Default::default()
});
let cmds = vec!["bun install", "bun generate"];
let result = resolve_post_setup_commands(true, Some(&profile), &cmds);
assert_eq!(result, Some(Vec::<&str>::new()));
}
#[test]
fn test_resolve_post_setup_none_keyword() {
let profile = make_profile(ProfileDefaults {
post_setup: Some(PostSetupMode::Keyword(PostSetupKeyword::None)),
..Default::default()
});
let cmds = vec!["bun install", "bun generate"];
let result = resolve_post_setup_commands(false, Some(&profile), &cmds);
assert_eq!(result, Some(Vec::<&str>::new()));
}
#[test]
fn test_resolve_post_setup_all_keyword() {
let profile = make_profile(ProfileDefaults {
post_setup: Some(PostSetupMode::Keyword(PostSetupKeyword::All)),
..Default::default()
});
let cmds = vec!["bun install", "bun generate"];
let result = resolve_post_setup_commands(false, Some(&profile), &cmds);
assert_eq!(result, Some(vec!["bun install", "bun generate"]));
}
#[test]
fn test_resolve_post_setup_all_with_skip() {
let profile = make_profile(ProfileDefaults {
post_setup: Some(PostSetupMode::Keyword(PostSetupKeyword::All)),
skip_post_setup: vec!["bun generate".to_string()],
..Default::default()
});
let cmds = vec!["bun install", "bun generate", "bun build"];
let result = resolve_post_setup_commands(false, Some(&profile), &cmds);
assert_eq!(result, Some(vec!["bun install", "bun build"]));
}
#[test]
fn test_resolve_post_setup_commands_list() {
let profile = make_profile(ProfileDefaults {
post_setup: Some(PostSetupMode::Commands(vec!["bun install".to_string()])),
..Default::default()
});
let cmds = vec!["bun install", "bun generate", "bun build"];
let result = resolve_post_setup_commands(false, Some(&profile), &cmds);
assert_eq!(result, Some(vec!["bun install"]));
}
#[test]
fn test_resolve_post_setup_commands_list_no_match() {
let profile = make_profile(ProfileDefaults {
post_setup: Some(PostSetupMode::Commands(vec!["nonexistent".to_string()])),
..Default::default()
});
let cmds = vec!["bun install", "bun generate"];
let result = resolve_post_setup_commands(false, Some(&profile), &cmds);
assert_eq!(result, Some(Vec::<&str>::new()));
}
#[test]
fn test_resolve_post_setup_not_set_returns_none() {
let profile = make_profile(ProfileDefaults::default());
let cmds = vec!["bun install"];
let result = resolve_post_setup_commands(false, Some(&profile), &cmds);
assert_eq!(result, None);
}
#[test]
fn test_resolve_post_setup_no_profile_returns_none() {
let cmds = vec!["bun install"];
let result = resolve_post_setup_commands(false, None, &cmds);
assert_eq!(result, None);
}
#[test]
fn test_resolve_post_setup_no_install_without_profile() {
let cmds = vec!["bun install"];
let result = resolve_post_setup_commands(true, None, &cmds);
assert_eq!(result, Some(Vec::<&str>::new()));
}
#[test]
fn test_resolve_overwrite_flag_wins() {
let profile = make_profile(ProfileDefaults {
overwrite_existing: Some(false),
..Default::default()
});
let result = resolve_overwrite(true, Some(&profile));
assert_eq!(result, Some(true));
}
#[test]
fn test_resolve_overwrite_from_profile() {
let profile = make_profile(ProfileDefaults {
overwrite_existing: Some(true),
..Default::default()
});
let result = resolve_overwrite(false, Some(&profile));
assert_eq!(result, Some(true));
let profile_false = make_profile(ProfileDefaults {
overwrite_existing: Some(false),
..Default::default()
});
let result = resolve_overwrite(false, Some(&profile_false));
assert_eq!(result, Some(false));
}
#[test]
fn test_resolve_overwrite_neither_set() {
let result = resolve_overwrite(false, None);
assert_eq!(result, None);
let profile = make_profile(ProfileDefaults::default());
let result = resolve_overwrite(false, Some(&profile));
assert_eq!(result, None);
}
#[test]
fn test_is_glob_pattern() {
assert!(!is_glob_pattern("node_modules"));
assert!(!is_glob_pattern(".turbo"));
assert!(!is_glob_pattern("path/to/dir"));
assert!(is_glob_pattern("**/dist"));
assert!(is_glob_pattern("*.log"));
assert!(is_glob_pattern("src/[ab]"));
assert!(is_glob_pattern("dir?name"));
}
#[test]
fn test_format_size() {
assert_eq!(output::format_size(0), "0 B");
assert_eq!(output::format_size(512), "512 B");
assert_eq!(output::format_size(1023), "1023 B");
assert_eq!(output::format_size(1024), "1.0 KiB");
assert_eq!(output::format_size(1536), "1.5 KiB");
assert_eq!(output::format_size(1_048_576), "1.0 MiB");
assert_eq!(output::format_size(1_572_864), "1.5 MiB");
assert_eq!(output::format_size(1_073_741_824), "1.0 GiB");
}
fn make_loaded_config_with_clean(
relative_path: &str,
config_dir: &Path,
clean: Vec<String>,
) -> LoadedConfig {
LoadedConfig {
config: worktree_setup_config::Config {
clean,
..Default::default()
},
config_path: config_dir.join("worktree.config.toml"),
config_dir: config_dir.to_path_buf(),
relative_path: relative_path.to_string(),
}
}
#[test]
fn test_resolve_clean_exact_paths() {
let dir = tempfile::tempdir().unwrap();
let repo_root = dir.path();
let config_dir = repo_root.join("apps/my-app");
std::fs::create_dir_all(&config_dir).unwrap();
let target = repo_root.join("worktree");
let target_app_dir = target.join("apps/my-app");
std::fs::create_dir_all(&target_app_dir).unwrap();
std::fs::create_dir_all(target_app_dir.join("node_modules")).unwrap();
std::fs::write(target_app_dir.join("node_modules/pkg.js"), "data").unwrap();
std::fs::create_dir_all(target_app_dir.join(".turbo")).unwrap();
let config = make_loaded_config_with_clean(
"apps/my-app/worktree.config.toml",
&config_dir,
vec!["node_modules".to_string(), ".turbo".to_string()],
);
let target_canonical = target.canonicalize().unwrap();
let resolved = resolve_clean_paths(&[&config], &target, &target_canonical, repo_root);
assert_eq!(resolved.len(), 2);
let rel_paths: Vec<&str> = resolved.iter().map(|(_, r)| r.as_str()).collect();
assert!(rel_paths.iter().any(|p| p.contains("node_modules")));
assert!(rel_paths.iter().any(|p| p.contains(".turbo")));
}
#[test]
fn test_resolve_clean_glob_patterns() {
let dir = tempfile::tempdir().unwrap();
let repo_root = dir.path();
let config_dir = repo_root.join("apps/my-app");
std::fs::create_dir_all(&config_dir).unwrap();
let target = repo_root.join("worktree");
let target_app_dir = target.join("apps/my-app");
std::fs::create_dir_all(target_app_dir.join("dist")).unwrap();
std::fs::write(target_app_dir.join("dist/bundle.js"), "code").unwrap();
std::fs::create_dir_all(target_app_dir.join("src/dist")).unwrap();
std::fs::write(target_app_dir.join("src/dist/out.js"), "code").unwrap();
std::fs::create_dir_all(target_app_dir.join("src/lib")).unwrap();
let config = make_loaded_config_with_clean(
"apps/my-app/worktree.config.toml",
&config_dir,
vec!["**/dist".to_string()],
);
let target_canonical = target.canonicalize().unwrap();
let resolved = resolve_clean_paths(&[&config], &target, &target_canonical, repo_root);
assert_eq!(resolved.len(), 2);
let rel_paths: Vec<&str> = resolved.iter().map(|(_, r)| r.as_str()).collect();
assert!(rel_paths.iter().all(|p| p.contains("dist")));
assert!(!rel_paths.iter().any(|p| p.contains("lib")));
}
#[test]
fn test_resolve_clean_nonexistent_paths_skipped() {
let dir = tempfile::tempdir().unwrap();
let repo_root = dir.path();
let config_dir = repo_root.join("apps/my-app");
std::fs::create_dir_all(&config_dir).unwrap();
let target = repo_root.join("worktree");
let target_app_dir = target.join("apps/my-app");
std::fs::create_dir_all(&target_app_dir).unwrap();
let config = make_loaded_config_with_clean(
"apps/my-app/worktree.config.toml",
&config_dir,
vec!["node_modules".to_string()],
);
let target_canonical = target.canonicalize().unwrap();
let resolved = resolve_clean_paths(&[&config], &target, &target_canonical, repo_root);
assert!(resolved.is_empty());
}
#[test]
fn test_resolve_clean_containment_rejects_escape() {
let dir = tempfile::tempdir().unwrap();
let repo_root = dir.path();
let config_dir = repo_root.join("apps/my-app");
std::fs::create_dir_all(&config_dir).unwrap();
let target = repo_root.join("worktree");
let target_app_dir = target.join("apps/my-app");
std::fs::create_dir_all(&target_app_dir).unwrap();
std::fs::write(repo_root.join("secret.txt"), "sensitive").unwrap();
let config = make_loaded_config_with_clean(
"apps/my-app/worktree.config.toml",
&config_dir,
vec!["../../../secret.txt".to_string()],
);
let target_canonical = target.canonicalize().unwrap();
let resolved = resolve_clean_paths(&[&config], &target, &target_canonical, repo_root);
assert!(resolved.is_empty());
}
#[test]
fn test_resolve_clean_dedup_across_configs() {
let dir = tempfile::tempdir().unwrap();
let repo_root = dir.path();
let config_dir = repo_root.join("apps/my-app");
std::fs::create_dir_all(&config_dir).unwrap();
let target = repo_root.join("worktree");
let target_app_dir = target.join("apps/my-app");
std::fs::create_dir_all(target_app_dir.join("node_modules")).unwrap();
let config1 = make_loaded_config_with_clean(
"apps/my-app/worktree.config.toml",
&config_dir,
vec!["node_modules".to_string()],
);
let config2 = make_loaded_config_with_clean(
"apps/my-app/worktree.local.config.toml",
&config_dir,
vec!["node_modules".to_string()],
);
let target_canonical = target.canonicalize().unwrap();
let resolved =
resolve_clean_paths(&[&config1, &config2], &target, &target_canonical, repo_root);
assert_eq!(resolved.len(), 1);
}
#[test]
fn test_resolve_clean_filters_descendants() {
let dir = tempfile::tempdir().unwrap();
let repo_root = dir.path();
let config_dir = repo_root.join("apps/my-app");
std::fs::create_dir_all(&config_dir).unwrap();
let target = repo_root.join("worktree");
let target_app_dir = target.join("apps/my-app");
std::fs::create_dir_all(target_app_dir.join("node_modules/pkg/dist")).unwrap();
std::fs::write(
target_app_dir.join("node_modules/pkg/dist/index.js"),
"code",
)
.unwrap();
std::fs::create_dir_all(target_app_dir.join("packages/utils/dist")).unwrap();
std::fs::write(target_app_dir.join("packages/utils/dist/index.js"), "code").unwrap();
let config = make_loaded_config_with_clean(
"apps/my-app/worktree.config.toml",
&config_dir,
vec!["node_modules".to_string(), "**/dist".to_string()],
);
let target_canonical = target.canonicalize().unwrap();
let resolved = resolve_clean_paths(&[&config], &target, &target_canonical, repo_root);
assert_eq!(resolved.len(), 2);
let rel_paths: Vec<&str> = resolved.iter().map(|(_, r)| r.as_str()).collect();
assert!(rel_paths.iter().any(|p| p.ends_with("node_modules")));
assert!(rel_paths.iter().any(|p| p.ends_with("packages/utils/dist")));
assert!(!rel_paths.iter().any(|p| p.contains("node_modules/pkg")));
}
#[test]
fn test_resolve_clean_glob_skips_symlinks() {
let dir = tempfile::tempdir().unwrap();
let repo_root = dir.path();
let config_dir = repo_root.join("apps/my-app");
std::fs::create_dir_all(&config_dir).unwrap();
let target = repo_root.join("worktree");
let target_app_dir = target.join("apps/my-app");
std::fs::create_dir_all(target_app_dir.join("src/dist")).unwrap();
std::fs::write(target_app_dir.join("src/dist/bundle.js"), "code").unwrap();
let cache_dir = target.join("cache/pkg");
std::fs::create_dir_all(cache_dir.join("dist")).unwrap();
std::fs::write(cache_dir.join("dist/cached.js"), "cached").unwrap();
std::fs::create_dir_all(target_app_dir.join("node_modules")).unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(&cache_dir, target_app_dir.join("node_modules/pkg")).unwrap();
let config = make_loaded_config_with_clean(
"apps/my-app/worktree.config.toml",
&config_dir,
vec!["**/dist".to_string()],
);
let target_canonical = target.canonicalize().unwrap();
let resolved = resolve_clean_paths(&[&config], &target, &target_canonical, repo_root);
#[cfg(unix)]
{
assert_eq!(resolved.len(), 1);
assert!(resolved[0].1.contains("src/dist"));
}
#[cfg(not(unix))]
{
assert_eq!(resolved.len(), 1);
}
}
#[test]
fn test_resolve_clean_root_relative_exact() {
let dir = tempfile::tempdir().unwrap();
let repo_root = dir.path();
let config_dir = repo_root.join("apps/my-app");
std::fs::create_dir_all(&config_dir).unwrap();
let target = repo_root.join("worktree");
std::fs::create_dir_all(target.join("node_modules/pkg")).unwrap();
std::fs::write(target.join("node_modules/pkg/index.js"), "code").unwrap();
std::fs::create_dir_all(target.join("apps/my-app")).unwrap();
let config = make_loaded_config_with_clean(
"apps/my-app/worktree.config.toml",
&config_dir,
vec!["/node_modules".to_string()],
);
let target_canonical = target.canonicalize().unwrap();
let resolved = resolve_clean_paths(&[&config], &target, &target_canonical, repo_root);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].1, "node_modules");
}
#[test]
fn test_resolve_clean_root_relative_glob() {
let dir = tempfile::tempdir().unwrap();
let repo_root = dir.path();
let config_dir = repo_root.join("apps/my-app");
std::fs::create_dir_all(&config_dir).unwrap();
let target = repo_root.join("worktree");
std::fs::create_dir_all(target.join("apps/my-app/dist")).unwrap();
std::fs::write(target.join("apps/my-app/dist/bundle.js"), "code").unwrap();
std::fs::create_dir_all(target.join("packages/utils/dist")).unwrap();
std::fs::write(target.join("packages/utils/dist/lib.js"), "code").unwrap();
std::fs::create_dir_all(target.join("packages/utils/src")).unwrap();
let config = make_loaded_config_with_clean(
"apps/my-app/worktree.config.toml",
&config_dir,
vec!["/**/dist".to_string()],
);
let target_canonical = target.canonicalize().unwrap();
let resolved = resolve_clean_paths(&[&config], &target, &target_canonical, repo_root);
assert_eq!(resolved.len(), 2);
let rel_paths: Vec<&str> = resolved.iter().map(|(_, r)| r.as_str()).collect();
assert!(rel_paths.iter().all(|p| p.contains("dist")));
assert!(rel_paths.iter().any(|p| p.contains("apps/my-app/dist")));
assert!(rel_paths.iter().any(|p| p.contains("packages/utils/dist")));
}
#[test]
fn test_resolve_clean_mixed_relative_and_root() {
let dir = tempfile::tempdir().unwrap();
let repo_root = dir.path();
let config_dir = repo_root.join("apps/my-app");
std::fs::create_dir_all(&config_dir).unwrap();
let target = repo_root.join("worktree");
let target_app_dir = target.join("apps/my-app");
std::fs::create_dir_all(target_app_dir.join("node_modules")).unwrap();
std::fs::create_dir_all(target.join(".turbo")).unwrap();
let config = make_loaded_config_with_clean(
"apps/my-app/worktree.config.toml",
&config_dir,
vec![
"node_modules".to_string(), "/.turbo".to_string(), ],
);
let target_canonical = target.canonicalize().unwrap();
let resolved = resolve_clean_paths(&[&config], &target, &target_canonical, repo_root);
assert_eq!(resolved.len(), 2);
let rel_paths: Vec<&str> = resolved.iter().map(|(_, r)| r.as_str()).collect();
assert!(rel_paths.iter().any(|p| p.contains("node_modules")));
assert!(rel_paths.iter().any(|p| p == &".turbo"));
}
#[test]
fn test_resolve_clean_glob_prunes_matched_dirs() {
let dir = tempfile::tempdir().unwrap();
let repo_root = dir.path();
let config_dir = repo_root.join("apps/my-app");
std::fs::create_dir_all(&config_dir).unwrap();
let target = repo_root.join("worktree");
let target_app_dir = target.join("apps/my-app");
std::fs::create_dir_all(target_app_dir.join("node_modules/pkg/node_modules/nested-pkg"))
.unwrap();
std::fs::write(
target_app_dir.join("node_modules/pkg/node_modules/nested-pkg/index.js"),
"code",
)
.unwrap();
let config = make_loaded_config_with_clean(
"apps/my-app/worktree.config.toml",
&config_dir,
vec!["**/node_modules".to_string()],
);
let target_canonical = target.canonicalize().unwrap();
let resolved = resolve_clean_paths(&[&config], &target, &target_canonical, repo_root);
assert_eq!(resolved.len(), 1);
assert!(resolved[0].1.ends_with("node_modules"));
assert!(!resolved[0].1.contains("pkg"));
}
#[test]
fn test_path_size_does_not_follow_symlinks() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
let real_dir = base.join("real");
std::fs::create_dir_all(&real_dir).unwrap();
std::fs::write(real_dir.join("file.txt"), "hello world").unwrap();
#[cfg(unix)]
{
std::os::unix::fs::symlink(&real_dir, base.join("link")).unwrap();
let parent = base.join("parent");
std::fs::create_dir_all(&parent).unwrap();
std::os::unix::fs::symlink(&real_dir, parent.join("link_inside")).unwrap();
std::fs::write(parent.join("own_file.txt"), "data").unwrap();
let size = path_size(&parent);
let real_dir_size = path_size(&real_dir);
assert!(size > 0);
assert!(
size < real_dir_size * 2,
"symlink target should not be counted"
);
}
let size = path_size(&real_dir);
assert!(size > 0);
}
#[cfg(unix)]
#[test]
fn test_path_size_counts_hardlinks_fully() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
let original = base.join("original.txt");
std::fs::write(&original, "hardlink test data").unwrap();
std::fs::hard_link(&original, base.join("hardlink.txt")).unwrap();
let size = path_size(base);
let single_size = path_size(&original);
assert_eq!(size, single_size * 2);
}
#[test]
fn test_worktree_clean_label_with_branch() {
let wt = worktree_setup_git::WorktreeInfo {
path: PathBuf::from("/repo/feature"),
is_main: false,
branch: Some("feature-auth".to_string()),
commit: Some("abc12345".to_string()),
};
assert_eq!(worktree_clean_label(&wt), "feature-auth");
}
#[test]
fn test_worktree_clean_label_main_worktree() {
let wt = worktree_setup_git::WorktreeInfo {
path: PathBuf::from("/repo"),
is_main: true,
branch: Some("master".to_string()),
commit: Some("abc12345".to_string()),
};
assert_eq!(worktree_clean_label(&wt), "master [main]");
}
#[test]
fn test_worktree_clean_label_no_branch() {
let wt = worktree_setup_git::WorktreeInfo {
path: PathBuf::from("/repo/detached-wt"),
is_main: false,
branch: None,
commit: Some("abc12345".to_string()),
};
assert_eq!(worktree_clean_label(&wt), "detached-wt");
}
#[test]
fn test_worktree_clean_label_no_branch_main() {
let wt = worktree_setup_git::WorktreeInfo {
path: PathBuf::from("/repo"),
is_main: true,
branch: None,
commit: Some("abc12345".to_string()),
};
assert_eq!(worktree_clean_label(&wt), "repo [main]");
}
#[test]
fn test_resolve_multi_worktree_clean() {
let dir = tempfile::tempdir().unwrap();
let repo_root = dir.path();
let config_dir = repo_root.join("apps/my-app");
std::fs::create_dir_all(&config_dir).unwrap();
let wt1_path = repo_root.join("wt1");
let wt2_path = repo_root.join("wt2");
std::fs::create_dir_all(wt1_path.join("apps/my-app/node_modules")).unwrap();
std::fs::write(wt1_path.join("apps/my-app/node_modules/pkg.js"), "data1").unwrap();
std::fs::create_dir_all(wt2_path.join("apps/my-app/node_modules")).unwrap();
std::fs::write(wt2_path.join("apps/my-app/node_modules/pkg.js"), "data2").unwrap();
std::fs::create_dir_all(wt2_path.join("apps/my-app/.turbo")).unwrap();
let config = make_loaded_config_with_clean(
"apps/my-app/worktree.config.toml",
&config_dir,
vec!["node_modules".to_string(), ".turbo".to_string()],
);
let wt1 = worktree_setup_git::WorktreeInfo {
path: wt1_path,
is_main: false,
branch: Some("feature-a".to_string()),
commit: None,
};
let wt2 = worktree_setup_git::WorktreeInfo {
path: wt2_path,
is_main: false,
branch: Some("feature-b".to_string()),
commit: None,
};
let worktrees = vec![&wt1, &wt2];
let configs = vec![&config];
let groups = resolve_multi_worktree_clean(&worktrees, &configs, repo_root);
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].label, "feature-a");
assert_eq!(groups[0].items.len(), 1); assert_eq!(groups[1].label, "feature-b");
assert_eq!(groups[1].items.len(), 2); }
#[test]
fn test_resolve_multi_worktree_clean_empty_worktree() {
let dir = tempfile::tempdir().unwrap();
let repo_root = dir.path();
let config_dir = repo_root.join("apps/my-app");
std::fs::create_dir_all(&config_dir).unwrap();
let wt_path = repo_root.join("wt-empty");
std::fs::create_dir_all(wt_path.join("apps/my-app")).unwrap();
let config = make_loaded_config_with_clean(
"apps/my-app/worktree.config.toml",
&config_dir,
vec!["node_modules".to_string()],
);
let wt = worktree_setup_git::WorktreeInfo {
path: wt_path,
is_main: false,
branch: Some("empty-branch".to_string()),
commit: None,
};
let worktrees = vec![&wt];
let configs = vec![&config];
let groups = resolve_multi_worktree_clean(&worktrees, &configs, repo_root);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].label, "empty-branch");
assert!(groups[0].items.is_empty());
}
fn make_worktree_info(path: &Path, is_main: bool, branch: Option<&str>) -> WorktreeInfo {
WorktreeInfo {
path: path.to_path_buf(),
is_main,
branch: branch.map(String::from),
commit: None,
}
}
#[test]
fn test_find_containing_linked_worktree_finds_match() {
let dir = tempfile::tempdir().unwrap();
let main_path = dir.path().join("main");
let linked_path = dir.path().join("linked");
std::fs::create_dir_all(&main_path).unwrap();
std::fs::create_dir_all(linked_path.join("subdir")).unwrap();
let worktrees = vec![
make_worktree_info(&main_path, true, Some("master")),
make_worktree_info(&linked_path, false, Some("feature")),
];
let cwd = linked_path.canonicalize().unwrap().join("subdir");
let result = find_containing_linked_worktree(&cwd, &worktrees);
assert!(result.is_some());
assert_eq!(result.unwrap().branch.as_deref(), Some("feature"));
}
#[test]
fn test_find_containing_linked_worktree_ignores_main() {
let dir = tempfile::tempdir().unwrap();
let main_path = dir.path().join("main");
std::fs::create_dir_all(&main_path).unwrap();
let worktrees = vec![make_worktree_info(&main_path, true, Some("master"))];
let cwd = main_path.canonicalize().unwrap();
let result = find_containing_linked_worktree(&cwd, &worktrees);
assert!(result.is_none());
}
#[test]
fn test_find_containing_linked_worktree_no_match() {
let dir = tempfile::tempdir().unwrap();
let main_path = dir.path().join("main");
let linked_path = dir.path().join("linked");
let other_path = dir.path().join("other");
std::fs::create_dir_all(&main_path).unwrap();
std::fs::create_dir_all(&linked_path).unwrap();
std::fs::create_dir_all(&other_path).unwrap();
let worktrees = vec![
make_worktree_info(&main_path, true, Some("master")),
make_worktree_info(&linked_path, false, Some("feature")),
];
let cwd = other_path.canonicalize().unwrap();
let result = find_containing_linked_worktree(&cwd, &worktrees);
assert!(result.is_none());
}
#[test]
fn test_find_containing_linked_worktree_exact_root() {
let dir = tempfile::tempdir().unwrap();
let linked_path = dir.path().join("linked");
std::fs::create_dir_all(&linked_path).unwrap();
let worktrees = vec![make_worktree_info(&linked_path, false, Some("feature"))];
let cwd = linked_path.canonicalize().unwrap();
let result = find_containing_linked_worktree(&cwd, &worktrees);
assert!(result.is_some());
}
fn create_test_repo(dir: &Path) {
Command::new("git")
.args(["init"])
.current_dir(dir)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(dir)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(dir)
.output()
.unwrap();
std::fs::write(dir.join("README.md"), "# Test").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(dir)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(dir)
.output()
.unwrap();
}
#[test]
fn test_worktree_has_changes_clean_repo() {
let dir = tempfile::tempdir().unwrap();
create_test_repo(dir.path());
assert!(
!worktree_has_changes(dir.path()),
"clean repo should have no changes"
);
}
#[test]
fn test_worktree_has_changes_dirty_repo() {
let dir = tempfile::tempdir().unwrap();
create_test_repo(dir.path());
std::fs::write(dir.path().join("new-file.txt"), "content").unwrap();
assert!(
worktree_has_changes(dir.path()),
"repo with untracked file should have changes"
);
}
#[test]
fn test_worktree_has_changes_unstaged_modification() {
let dir = tempfile::tempdir().unwrap();
create_test_repo(dir.path());
std::fs::write(dir.path().join("README.md"), "# Modified").unwrap();
assert!(
worktree_has_changes(dir.path()),
"repo with unstaged modification should have changes"
);
}
#[test]
fn test_worktree_has_changes_nonexistent_path() {
assert!(
!worktree_has_changes(Path::new("/nonexistent/path")),
"nonexistent path should return false"
);
}
#[test]
fn test_handle_branch_deletion_never_policy() {
let dir = tempfile::tempdir().unwrap();
create_test_repo(dir.path());
let repo = worktree_setup_git::open_repo(dir.path()).unwrap();
Command::new("git")
.args(["branch", "test-branch"])
.current_dir(dir.path())
.output()
.unwrap();
let result = handle_branch_deletion(
&repo,
"test-branch",
BranchDeletePolicy::Never,
false,
false,
false,
)
.unwrap();
assert!(result.is_none(), "Never policy should return None");
let output = Command::new("git")
.args(["branch", "--list", "test-branch"])
.current_dir(dir.path())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("test-branch"),
"branch should still exist after Never policy"
);
}
#[test]
fn test_handle_branch_deletion_always_policy() {
let dir = tempfile::tempdir().unwrap();
create_test_repo(dir.path());
let repo = worktree_setup_git::open_repo(dir.path()).unwrap();
Command::new("git")
.args(["branch", "auto-delete-branch"])
.current_dir(dir.path())
.output()
.unwrap();
let result = handle_branch_deletion(
&repo,
"auto-delete-branch",
BranchDeletePolicy::Always,
true, false,
false,
)
.unwrap();
assert_eq!(result, Some("auto-delete-branch".to_string()));
let output = Command::new("git")
.args(["branch", "--list", "auto-delete-branch"])
.current_dir(dir.path())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains("auto-delete-branch"),
"branch should be deleted"
);
}
#[test]
fn test_handle_branch_deletion_ask_non_interactive_skips() {
let dir = tempfile::tempdir().unwrap();
create_test_repo(dir.path());
let repo = worktree_setup_git::open_repo(dir.path()).unwrap();
Command::new("git")
.args(["branch", "ask-branch"])
.current_dir(dir.path())
.output()
.unwrap();
let result = handle_branch_deletion(
&repo,
"ask-branch",
BranchDeletePolicy::Ask,
true, false,
false,
)
.unwrap();
assert!(
result.is_none(),
"Ask policy in non-interactive mode should skip"
);
let output = Command::new("git")
.args(["branch", "--list", "ask-branch"])
.current_dir(dir.path())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("ask-branch"), "branch should still exist");
}
#[test]
fn test_handle_branch_deletion_dry_run() {
let dir = tempfile::tempdir().unwrap();
create_test_repo(dir.path());
let repo = worktree_setup_git::open_repo(dir.path()).unwrap();
Command::new("git")
.args(["branch", "dry-run-branch"])
.current_dir(dir.path())
.output()
.unwrap();
let result = handle_branch_deletion(
&repo,
"dry-run-branch",
BranchDeletePolicy::Always,
false,
false,
true, )
.unwrap();
assert_eq!(result, Some("dry-run-branch".to_string()));
let output = Command::new("git")
.args(["branch", "--list", "dry-run-branch"])
.current_dir(dir.path())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("dry-run-branch"),
"branch should still exist after dry run"
);
}
#[test]
fn test_handle_branch_deletion_force_deletes_unmerged() {
let dir = tempfile::tempdir().unwrap();
create_test_repo(dir.path());
let wt_path = dir.path().join("unmerged-wt");
Command::new("git")
.args(["worktree", "add", "-b", "unmerged-force-branch"])
.arg(&wt_path)
.current_dir(dir.path())
.output()
.unwrap();
std::fs::write(wt_path.join("extra.txt"), "extra").unwrap();
Command::new("git")
.args(["add", "extra.txt"])
.current_dir(&wt_path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "unmerged work"])
.current_dir(&wt_path)
.output()
.unwrap();
Command::new("git")
.args(["worktree", "remove", "--force"])
.arg(&wt_path)
.current_dir(dir.path())
.output()
.unwrap();
let repo = worktree_setup_git::open_repo(dir.path()).unwrap();
let result = handle_branch_deletion(
&repo,
"unmerged-force-branch",
BranchDeletePolicy::Always,
false,
true, false,
)
.unwrap();
assert_eq!(result, Some("unmerged-force-branch".to_string()));
let output = Command::new("git")
.args(["branch", "--list", "unmerged-force-branch"])
.current_dir(dir.path())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains("unmerged-force-branch"),
"unmerged branch should be force-deleted"
);
}
}