#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
#![allow(clippy::multiple_crate_versions)]
use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::time::Duration;
use colored::Colorize;
use console::{Key, Term};
use dialoguer::{Confirm, Input, MultiSelect, Select};
use worktree_setup_config::{CreationMethod, LoadedConfig};
use worktree_setup_git::{
Repository, WorktreeCreateOptions, WorktreeInfo, fetch_remote, get_remote_branches, get_remotes,
};
use crate::output;
pub fn select_configs(configs: &[LoadedConfig]) -> io::Result<Vec<usize>> {
if configs.len() == 1 {
return Ok(vec![0]);
}
let items: Vec<String> = configs
.iter()
.map(|c| format!("{} - {}", c.relative_path, c.config.description))
.collect();
let selections = MultiSelect::new()
.with_prompt("Select configurations to apply")
.items(&items)
.defaults(&vec![true; items.len()])
.interact()?;
Ok(selections)
}
#[must_use]
fn format_worktree_label(wt: &WorktreeInfo) -> String {
let suffix = if wt.is_main { " [main]" } else { "" };
wt.branch.as_ref().map_or_else(
|| {
wt.commit.as_ref().map_or_else(
|| format!("({}){suffix}", wt.path.display()),
|commit| format!("detached@{commit} ({}){suffix}", wt.path.display()),
)
},
|branch| format!("{branch} ({}){suffix}", wt.path.display()),
)
}
pub struct WorktreeResolution {
pub index: usize,
pub resolved: Vec<(PathBuf, String)>,
pub items: Vec<output::CleanItem>,
pub summary: String,
}
pub struct WarningResolution {
pub index: usize,
pub warning: Option<String>,
}
#[derive(Clone)]
enum WarningStatus {
Pending,
Clean,
Warning(String),
}
pub type RemovalPickerResult = (Option<Vec<usize>>, Vec<Option<String>>);
const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const SPINNER_TICK_MS: u64 = 80;
#[allow(clippy::too_many_lines)]
pub fn select_worktrees_with_sizes(
worktrees: &[WorktreeInfo],
result_rx: &mpsc::Receiver<WorktreeResolution>,
done: &AtomicBool,
) -> io::Result<(Option<Vec<usize>>, Vec<WorktreeResolution>)> {
let count = worktrees.len();
if count == 0 {
return Ok((Some(Vec::new()), Vec::new()));
}
let term = Term::stderr();
let labels: Vec<String> = worktrees.iter().map(format_worktree_label).collect();
let mut state = SelectState {
checked: vec![false; count],
statuses: vec![None; count],
resolutions: Vec::new(),
cursor: 0,
spinner_frame: 0,
};
let (key_tx, key_rx) = mpsc::channel::<Key>();
let input_done = std::sync::Arc::new(AtomicBool::new(false));
let input_done_clone = input_done.clone();
let input_term = term.clone();
let input_handle = std::thread::spawn(move || {
loop {
if input_done_clone.load(Ordering::Relaxed) {
break;
}
if let Ok(key) = input_term.read_key()
&& key_tx.send(key).is_err()
{
break;
}
}
});
term.hide_cursor()?;
let prompt_line = format!(
"{} {}",
"?".green().bold(),
"Select worktrees to clean (space to toggle, enter to confirm):".bold()
);
term.write_line(&prompt_line)?;
render_items(
&term,
&labels,
&state.checked,
&state.statuses,
state.cursor,
state.spinner_frame,
)?;
let result = run_select_loop(&term, &labels, &mut state, &key_rx, result_rx, done);
term.show_cursor()?;
term.clear_last_lines(count + 1)?;
input_done.store(true, Ordering::Relaxed);
drop(input_handle);
result.map(|sel| (sel, state.resolutions))
}
struct SelectState {
checked: Vec<bool>,
statuses: Vec<Option<String>>,
resolutions: Vec<WorktreeResolution>,
cursor: usize,
spinner_frame: usize,
}
#[allow(clippy::too_many_arguments)]
fn run_select_loop(
term: &Term,
labels: &[String],
state: &mut SelectState,
key_rx: &mpsc::Receiver<Key>,
result_rx: &mpsc::Receiver<WorktreeResolution>,
done: &AtomicBool,
) -> io::Result<Option<Vec<usize>>> {
let count = labels.len();
loop {
let mut needs_redraw = false;
while let Ok(res) = result_rx.try_recv() {
if res.index < count {
state.statuses[res.index] = Some(res.summary.clone());
needs_redraw = true;
}
state.resolutions.push(res);
}
while let Ok(key) = key_rx.try_recv() {
needs_redraw = true;
match key {
Key::ArrowDown | Key::Tab | Key::Char('j') => {
state.cursor = (state.cursor + 1) % count;
}
Key::ArrowUp | Key::BackTab | Key::Char('k') => {
state.cursor = (state.cursor + count - 1) % count;
}
Key::Char(' ') => {
state.checked[state.cursor] = !state.checked[state.cursor];
}
Key::Char('a') => {
let all_checked = state.checked.iter().all(|&c| c);
for c in &mut state.checked {
*c = !all_checked;
}
}
Key::Enter => {
let selected: Vec<usize> = state
.checked
.iter()
.enumerate()
.filter(|(_, c)| **c)
.map(|(i, _)| i)
.collect();
return Ok(Some(selected));
}
Key::Escape | Key::Char('q') => {
return Ok(None);
}
_ => {
needs_redraw = false;
}
}
}
let has_pending =
state.statuses.iter().any(Option::is_none) && !done.load(Ordering::Relaxed);
if has_pending {
state.spinner_frame = (state.spinner_frame + 1) % SPINNER_FRAMES.len();
needs_redraw = true;
}
if needs_redraw {
term.clear_last_lines(count)?;
render_items(
term,
labels,
&state.checked,
&state.statuses,
state.cursor,
state.spinner_frame,
)?;
}
std::thread::sleep(Duration::from_millis(SPINNER_TICK_MS));
}
}
fn render_items(
term: &Term,
labels: &[String],
checked: &[bool],
statuses: &[Option<String>],
cursor: usize,
spinner_frame: usize,
) -> io::Result<()> {
for (i, label) in labels.iter().enumerate() {
let is_active = i == cursor;
let prefix = if is_active { ">" } else { " " };
let checkbox = if checked[i] { "[x]" } else { "[ ]" };
let status = statuses[i].as_ref().map_or_else(
|| {
let frame = SPINNER_FRAMES[spinner_frame];
format!(" {frame} resolving...").yellow().to_string()
},
|s| format!(" {s}").dimmed().to_string(),
);
term.write_line(&format!("{prefix} {checkbox} {label}{status}"))?;
}
term.flush()?;
Ok(())
}
pub fn select_worktrees_for_removal(
worktrees: &[WorktreeInfo],
warning_rx: &mpsc::Receiver<WarningResolution>,
done: &AtomicBool,
) -> io::Result<RemovalPickerResult> {
let count = worktrees.len();
if count == 0 {
return Ok((Some(Vec::new()), Vec::new()));
}
let labels: Vec<String> = worktrees.iter().map(format_worktree_label).collect();
let disabled: Vec<bool> = worktrees.iter().map(|wt| wt.is_main).collect();
let mut state = RemovalSelectState {
checked: vec![false; count],
warnings: worktrees
.iter()
.map(|wt| {
if wt.is_main {
WarningStatus::Clean
} else {
WarningStatus::Pending
}
})
.collect(),
cursor: 0,
spinner_frame: 0,
};
let term = Term::stderr();
let (key_tx, key_rx) = mpsc::channel::<Key>();
let input_done = std::sync::Arc::new(AtomicBool::new(false));
let input_done_clone = input_done.clone();
let input_term = term.clone();
let input_handle = std::thread::spawn(move || {
loop {
if input_done_clone.load(Ordering::Relaxed) {
break;
}
if let Ok(key) = input_term.read_key()
&& key_tx.send(key).is_err()
{
break;
}
}
});
term.hide_cursor()?;
let prompt_line = format!(
"{} {}",
"?".green().bold(),
"Select worktrees to remove (space to toggle, enter to confirm):".bold()
);
term.write_line(&prompt_line)?;
render_removal_items(
&term,
&labels,
&state.checked,
&disabled,
&state.warnings,
state.cursor,
state.spinner_frame,
)?;
let result = run_removal_select_loop(
&term, &labels, &mut state, &disabled, &key_rx, warning_rx, done,
);
term.show_cursor()?;
term.clear_last_lines(count + 1)?; input_done.store(true, Ordering::Relaxed);
drop(input_handle);
let final_warnings: Vec<Option<String>> = state
.warnings
.into_iter()
.map(|w| match w {
WarningStatus::Warning(text) => Some(text),
WarningStatus::Pending | WarningStatus::Clean => None,
})
.collect();
result.map(|sel| (sel, final_warnings))
}
struct RemovalSelectState {
checked: Vec<bool>,
warnings: Vec<WarningStatus>,
cursor: usize,
spinner_frame: usize,
}
#[allow(clippy::too_many_arguments)]
fn run_removal_select_loop(
term: &Term,
labels: &[String],
state: &mut RemovalSelectState,
disabled: &[bool],
key_rx: &mpsc::Receiver<Key>,
warning_rx: &mpsc::Receiver<WarningResolution>,
done: &AtomicBool,
) -> io::Result<Option<Vec<usize>>> {
let count = labels.len();
loop {
let mut needs_redraw = false;
while let Ok(res) = warning_rx.try_recv() {
if res.index < count {
state.warnings[res.index] = res
.warning
.map_or(WarningStatus::Clean, WarningStatus::Warning);
needs_redraw = true;
}
}
while let Ok(key) = key_rx.try_recv() {
needs_redraw = true;
match key {
Key::ArrowDown | Key::Tab | Key::Char('j') => {
state.cursor = (state.cursor + 1) % count;
}
Key::ArrowUp | Key::BackTab | Key::Char('k') => {
state.cursor = (state.cursor + count - 1) % count;
}
Key::Char(' ') => {
if !disabled[state.cursor] {
state.checked[state.cursor] = !state.checked[state.cursor];
}
}
Key::Char('a') => {
let all_enabled_checked = state
.checked
.iter()
.enumerate()
.filter(|(i, _)| !disabled[*i])
.all(|(_, &c)| c);
for (i, c) in state.checked.iter_mut().enumerate() {
if !disabled[i] {
*c = !all_enabled_checked;
}
}
}
Key::Enter => {
let selected: Vec<usize> = state
.checked
.iter()
.enumerate()
.filter(|(_, c)| **c)
.map(|(i, _)| i)
.collect();
return Ok(Some(selected));
}
Key::Escape | Key::Char('q') => {
return Ok(None);
}
_ => {
needs_redraw = false;
}
}
}
let has_pending = state
.warnings
.iter()
.any(|w| matches!(w, WarningStatus::Pending))
&& !done.load(Ordering::Relaxed);
if has_pending {
state.spinner_frame = (state.spinner_frame + 1) % SPINNER_FRAMES.len();
needs_redraw = true;
}
if needs_redraw {
term.clear_last_lines(count)?;
render_removal_items(
term,
labels,
&state.checked,
disabled,
&state.warnings,
state.cursor,
state.spinner_frame,
)?;
}
std::thread::sleep(Duration::from_millis(SPINNER_TICK_MS));
}
}
fn render_removal_items(
term: &Term,
labels: &[String],
checked: &[bool],
disabled: &[bool],
warnings: &[WarningStatus],
cursor: usize,
spinner_frame: usize,
) -> io::Result<()> {
for (i, label) in labels.iter().enumerate() {
let is_active = i == cursor;
let prefix = if is_active { ">" } else { " " };
if disabled[i] {
let line = format!("{prefix} [-] {label}").dimmed();
term.write_line(&line.to_string())?;
} else {
let checkbox = if checked[i] { "[x]" } else { "[ ]" };
let suffix = match warnings.get(i) {
Some(WarningStatus::Pending) | None => {
let frame = SPINNER_FRAMES[spinner_frame];
format!(" {frame} checking...").yellow().to_string()
}
Some(WarningStatus::Warning(w)) => {
format!(" {}", format!("({w})").yellow())
}
Some(WarningStatus::Clean) => String::new(),
};
term.write_line(&format!("{prefix} {checkbox} {label}{suffix}"))?;
}
}
term.flush()?;
Ok(())
}
pub fn prompt_worktree_path() -> io::Result<PathBuf> {
let path: String = Input::new()
.with_prompt("Enter the path for the new worktree")
.interact_text()?;
Ok(PathBuf::from(path))
}
fn prompt_base_branch(
default_branch: Option<&str>,
recent_branches: &[String],
profile_base: Option<&str>,
) -> io::Result<Option<String>> {
use std::collections::BTreeSet;
let mut options = vec!["Current HEAD".to_string()];
let mut seen = BTreeSet::new();
if let Some(branch) = default_branch {
options.push(branch.to_string());
seen.insert(branch.to_string());
}
for branch in recent_branches {
if !seen.contains(branch) {
options.push(branch.clone());
seen.insert(branch.clone());
}
}
if let Some(base) = profile_base
&& !seen.contains(base)
{
options.push(base.to_string());
seen.insert(base.to_string());
}
options.push("Enter custom branch/ref...".to_string());
let default_idx = profile_base
.and_then(|base| options.iter().position(|o| o == base))
.unwrap_or(0);
let choice = Select::new()
.with_prompt("Base the new branch off")
.items(&options)
.default(default_idx)
.interact()?;
let last_idx = options.len() - 1;
if choice == 0 {
Ok(None) } else if choice == last_idx {
let custom: String = Input::new()
.with_prompt("Enter branch name or ref")
.interact_text()?;
Ok(Some(custom))
} else {
Ok(Some(options[choice].clone()))
}
}
fn resolve_remote(repo: &Repository, override_name: Option<&str>) -> io::Result<String> {
if let Some(name) = override_name {
return Ok(name.to_string());
}
let remotes =
get_remotes(repo).map_err(|e| io::Error::other(format!("Failed to list remotes: {e}")))?;
match remotes.len() {
0 => Err(io::Error::other("No remotes configured in this repository")),
1 => Ok(remotes.into_iter().next().unwrap_or_default()),
_ => {
let idx = Select::new()
.with_prompt("Select remote")
.items(&remotes)
.default(0)
.interact()?;
Ok(remotes[idx].clone())
}
}
}
fn prompt_remote_branch(
repo: &Repository,
remote_override: Option<&str>,
inferred_branch: Option<&str>,
) -> io::Result<WorktreeCreateOptions> {
let remote = resolve_remote(repo, remote_override)?;
let should_fetch = Confirm::new()
.with_prompt(format!("Fetch latest from {remote}?"))
.default(true)
.interact()?;
if should_fetch {
println!("Fetching from {remote}...");
fetch_remote(repo, &remote)
.map_err(|e| io::Error::other(format!("Failed to fetch: {e}")))?;
}
let remote_branches = get_remote_branches(repo, &remote)
.map_err(|e| io::Error::other(format!("Failed to list remote branches: {e}")))?;
if remote_branches.is_empty() {
println!("No remote branches found. Using auto-named branch instead.");
return Ok(WorktreeCreateOptions::default());
}
let remote_prefix = format!("{remote}/");
if let Some(inferred) = inferred_branch {
let inferred_remote = format!("{remote_prefix}{inferred}");
if remote_branches.iter().any(|b| b == &inferred_remote) {
let use_inferred = Confirm::new()
.with_prompt(format!("Use inferred remote branch '{inferred_remote}'?"))
.default(true)
.interact()?;
if use_inferred {
return Ok(WorktreeCreateOptions {
branch: Some(inferred.to_string()),
..Default::default()
});
}
} else {
println!("No remote branch matching '{inferred_remote}' found. Showing all branches.");
}
}
let branch_idx = Select::new()
.with_prompt("Select remote branch")
.items(&remote_branches)
.interact()?;
let selected = &remote_branches[branch_idx];
let local_name = selected
.strip_prefix(&remote_prefix)
.unwrap_or(selected.as_str());
Ok(WorktreeCreateOptions {
branch: Some(local_name.to_string()),
..Default::default()
})
}
#[derive(Debug, Clone, Default)]
pub struct CreationProfileHints<'a> {
pub auto_create: bool,
pub creation_method: Option<&'a CreationMethod>,
pub base_branch: Option<&'a str>,
pub new_branch: bool,
pub remote_override: Option<&'a str>,
pub inferred_branch: Option<&'a str>,
}
fn build_creation_options(
worktree_name: &str,
current_branch: Option<&str>,
creation_method: Option<&CreationMethod>,
) -> (Vec<String>, Vec<&'static str>, usize) {
let mut options: Vec<String> = Vec::new();
let mut option_values: Vec<&str> = Vec::new();
options.push(format!("New branch (auto-named '{worktree_name}')"));
option_values.push("auto");
options.push("New branch (custom name)...".to_string());
option_values.push("new");
if let Some(branch) = current_branch {
options.push(format!("Use current branch ({branch})"));
option_values.push("current");
}
options.push("Use existing branch...".to_string());
option_values.push("existing");
options.push("Track remote branch...".to_string());
option_values.push("remote");
options.push("Detached HEAD (current commit)".to_string());
option_values.push("detach");
let default_key = match creation_method {
Some(CreationMethod::Remote) => "remote",
Some(CreationMethod::Current) => "current",
Some(CreationMethod::Detach) => "detach",
_ => "auto",
};
let default_choice = option_values
.iter()
.position(|v| *v == default_key)
.unwrap_or(0);
(options, option_values, default_choice)
}
fn dispatch_creation_method(
method: &CreationMethod,
repo: &Repository,
worktree_name: &str,
current_branch: Option<&str>,
hints: &CreationProfileHints<'_>,
) -> io::Result<WorktreeCreateOptions> {
match method {
CreationMethod::Auto => {
let base_branch = hints.base_branch.map(String::from);
if base_branch.is_some() {
Ok(WorktreeCreateOptions {
new_branch: Some(worktree_name.to_string()),
branch: base_branch,
..Default::default()
})
} else {
Ok(WorktreeCreateOptions::default())
}
}
CreationMethod::Current => Ok(WorktreeCreateOptions {
branch: current_branch.map(String::from),
..Default::default()
}),
CreationMethod::Remote => {
prompt_remote_branch(repo, hints.remote_override, hints.inferred_branch)
}
CreationMethod::Detach => Ok(WorktreeCreateOptions {
detach: true,
..Default::default()
}),
}
}
#[allow(clippy::too_many_arguments)]
pub fn prompt_worktree_create(
repo: &Repository,
target_path: &Path,
current_branch: Option<&str>,
branches: &[String],
default_branch: Option<&str>,
recent_branches: &[String],
hints: &CreationProfileHints<'_>,
) -> io::Result<Option<WorktreeCreateOptions>> {
if !hints.auto_create {
let should_create = Confirm::new()
.with_prompt(format!(
"Worktree does not exist at {}. Create it?",
target_path.display()
))
.default(true)
.interact()?;
if !should_create {
return Ok(None);
}
}
let worktree_name = target_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("worktree");
if let Some(method) = hints.creation_method {
let options = dispatch_creation_method(method, repo, worktree_name, current_branch, hints)?;
return Ok(Some(options));
}
let (options, option_values, default_choice) =
build_creation_options(worktree_name, current_branch, hints.creation_method);
let choice = Select::new()
.with_prompt("How should the worktree be created?")
.items(&options)
.default(default_choice)
.interact()?;
let selected_value = option_values[choice];
let result = match selected_value {
"auto" => {
let base_branch = if hints.new_branch && hints.base_branch.is_some() {
hints.base_branch.map(String::from)
} else {
prompt_base_branch(default_branch, recent_branches, hints.base_branch)?
};
if base_branch.is_some() {
WorktreeCreateOptions {
new_branch: Some(worktree_name.to_string()),
branch: base_branch,
..Default::default()
}
} else {
WorktreeCreateOptions::default()
}
}
"new" => {
let branch_name: String = Input::new()
.with_prompt("Enter new branch name")
.interact_text()?;
let base_branch =
prompt_base_branch(default_branch, recent_branches, hints.base_branch)?;
WorktreeCreateOptions {
new_branch: Some(branch_name),
branch: base_branch,
..Default::default()
}
}
"current" => WorktreeCreateOptions {
branch: current_branch.map(String::from),
..Default::default()
},
"existing" => {
if branches.is_empty() {
println!("No local branches found. Using auto-named branch instead.");
WorktreeCreateOptions::default()
} else {
let branch_idx = Select::new()
.with_prompt("Select branch")
.items(branches)
.interact()?;
WorktreeCreateOptions {
branch: Some(branches[branch_idx].clone()),
..Default::default()
}
}
}
"remote" => prompt_remote_branch(repo, hints.remote_override, hints.inferred_branch)?,
"detach" => WorktreeCreateOptions {
detach: true,
..Default::default()
},
_ => unreachable!(),
};
Ok(Some(result))
}
pub fn prompt_run_install(default: bool) -> io::Result<bool> {
Ok(Confirm::new()
.with_prompt("Run post-setup commands (e.g., bun install)?")
.default(default)
.interact()?)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StaleWorktreeAction {
Prune,
Force,
Cancel,
}
#[must_use = "caller must act on the chosen recovery action"]
pub fn prompt_stale_worktree_recovery() -> io::Result<StaleWorktreeAction> {
let options = [
"Prune stale worktrees and retry",
"Force create (overwrite registration)",
"Cancel",
];
let choice = Select::new()
.with_prompt("This path is registered as a stale worktree. How would you like to proceed?")
.items(options)
.default(0)
.interact()?;
Ok(match choice {
0 => StaleWorktreeAction::Prune,
1 => StaleWorktreeAction::Force,
_ => StaleWorktreeAction::Cancel,
})
}
#[derive(Debug, Clone)]
pub struct SetupOperationChoices {
pub run_files: bool,
pub overwrite_existing: bool,
pub run_post_setup: bool,
}
#[derive(Debug, Clone)]
pub struct SetupOperationInputs {
pub is_secondary_worktree: bool,
pub files: Option<bool>,
pub overwrite: Option<bool>,
pub post_setup: Option<bool>,
}
pub fn prompt_setup_operations(
inputs: &SetupOperationInputs,
post_setup_commands: &[&str],
) -> io::Result<SetupOperationChoices> {
let mut result = SetupOperationChoices {
run_files: inputs.files.unwrap_or(inputs.is_secondary_worktree),
overwrite_existing: inputs.overwrite.unwrap_or(false),
run_post_setup: inputs.post_setup.unwrap_or(!post_setup_commands.is_empty()),
};
let mut items: Vec<String> = Vec::new();
let mut checked: Vec<bool> = Vec::new();
let mut file_ops_index: Option<usize> = None;
let mut overwrite_index: Option<usize> = None;
let mut post_setup_index: Option<usize> = None;
if inputs.is_secondary_worktree && inputs.files.is_none() {
file_ops_index = Some(items.len());
items.push("Apply file operations (symlinks, copies, templates)".to_string());
checked.push(result.run_files);
}
if inputs.is_secondary_worktree && inputs.overwrite.is_none() {
overwrite_index = Some(items.len());
items.push("Overwrite existing files".to_string());
checked.push(result.overwrite_existing);
}
if !post_setup_commands.is_empty() && inputs.post_setup.is_none() {
post_setup_index = Some(items.len());
let cmds_display = post_setup_commands.join(", ");
items.push(format!("Run post-setup commands ({cmds_display})"));
checked.push(result.run_post_setup);
}
if items.is_empty() {
return Ok(result);
}
let selections = MultiSelect::new()
.with_prompt("Select what to run")
.items(&items)
.defaults(&checked)
.interact()?;
if let Some(i) = file_ops_index {
result.run_files = selections.contains(&i);
}
if let Some(i) = overwrite_index {
result.overwrite_existing = result.run_files && selections.contains(&i);
}
if let Some(i) = post_setup_index {
result.run_post_setup = selections.contains(&i);
}
Ok(result)
}