use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use anyhow::Context;
use color_print::cformat;
use worktrunk::config::UserConfig;
use worktrunk::git::{Repository, WorktreeInfo};
use worktrunk::path::format_path_for_display;
use worktrunk::shell_exec::Cmd;
use worktrunk::styling::{
eprintln, format_with_gutter, hint_message, info_message, progress_message, success_message,
warning_message,
};
use super::commit::{CommitGenerator, StageMode};
use super::worktree::{compute_worktree_path, paths_match};
pub struct RelocationCandidate {
pub wt: WorktreeInfo,
pub expected_path: PathBuf,
}
impl RelocationCandidate {
pub fn branch(&self) -> &str {
self.wt.branch.as_deref().unwrap()
}
}
pub struct GatherResult {
pub candidates: Vec<RelocationCandidate>,
pub template_errors: usize,
}
pub struct ValidatedCandidate {
pub wt: WorktreeInfo,
pub expected_path: PathBuf,
pub is_main: bool,
}
impl ValidatedCandidate {
pub fn branch(&self) -> &str {
self.wt.branch.as_deref().unwrap()
}
}
struct TempRelocation {
index: usize,
temp_path: PathBuf,
original_path: PathBuf,
}
pub struct RelocationExecutor {
pending: Vec<ValidatedCandidate>,
current_locations: HashMap<PathBuf, usize>,
blocked: HashSet<usize>,
moved: HashSet<usize>,
temp_relocated: Vec<TempRelocation>,
temp_dir: PathBuf,
pub skipped: usize,
pub relocated: usize,
}
pub fn gather_candidates(
repo: &Repository,
config: &UserConfig,
filter_branches: &[String],
) -> anyhow::Result<GatherResult> {
let worktrees: Vec<_> = repo
.list_worktrees()?
.into_iter()
.filter(|wt| wt.prunable.is_none())
.collect();
let worktrees: Vec<_> = if filter_branches.is_empty() {
worktrees
} else {
worktrees
.into_iter()
.filter(|wt| {
wt.branch
.as_ref()
.is_some_and(|b| filter_branches.iter().any(|arg| arg == b))
})
.collect()
};
let mut candidates = Vec::new();
let mut template_errors = 0;
for wt in worktrees {
let Some(branch) = wt.branch.as_deref() else {
continue; };
match compute_worktree_path(repo, branch, config) {
Ok(expected) => {
let actual_canonical = wt.path.canonicalize().unwrap_or_else(|_| wt.path.clone());
let expected_canonical =
expected.canonicalize().unwrap_or_else(|_| expected.clone());
if actual_canonical != expected_canonical {
candidates.push(RelocationCandidate {
wt,
expected_path: expected,
});
}
}
Err(e) => {
eprintln!(
"{}",
warning_message(cformat!(
"Skipping <bold>{branch}</> due to template error:"
))
);
eprintln!("{}", e);
template_errors += 1;
}
}
}
Ok(GatherResult {
candidates,
template_errors,
})
}
pub struct ValidationResult {
pub validated: Vec<ValidatedCandidate>,
pub skipped: usize,
}
pub fn validate_candidates(
repo: &Repository,
config: &UserConfig,
candidates: Vec<RelocationCandidate>,
auto_commit: bool,
repo_path: &Path,
) -> anyhow::Result<ValidationResult> {
let mut validated = Vec::new();
let mut skipped = 0;
for candidate in candidates {
let branch = candidate.branch();
if let Some(reason) = &candidate.wt.locked {
let reason_text = if reason.is_empty() {
String::new()
} else {
format!(": {reason}")
};
eprintln!(
"{}",
warning_message(cformat!("Skipping <bold>{branch}</> (locked{reason_text})"))
);
skipped += 1;
continue;
}
let worktree = repo.worktree_at(&candidate.wt.path);
if worktree.is_dirty()? {
if auto_commit {
eprintln!(
"{}",
progress_message(cformat!("Committing changes in <bold>{branch}</>..."))
);
worktree
.run_command(&["add", "-A"])
.context("Failed to stage changes")?;
let project_id = repo.project_identifier().ok();
let commit_config = config.commit_generation(project_id.as_deref());
CommitGenerator::new(&commit_config).commit_staged_changes(
&worktree,
false, false, StageMode::None, )?;
} else {
eprintln!(
"{}",
warning_message(cformat!("Skipping <bold>{branch}</> (uncommitted changes)"))
);
eprintln!(
"{}",
hint_message(cformat!(
"To auto-commit changes before relocating, use <underline>--commit</>"
))
);
skipped += 1;
continue;
}
}
let is_main = paths_match(&candidate.wt.path, repo_path);
validated.push(ValidatedCandidate {
wt: candidate.wt,
expected_path: candidate.expected_path,
is_main,
});
}
Ok(ValidationResult { validated, skipped })
}
impl RelocationExecutor {
pub fn new(
repo: &Repository,
validated: Vec<ValidatedCandidate>,
clobber: bool,
) -> anyhow::Result<Self> {
let temp_dir = repo.wt_dir().join("staging/relocate");
let mut current_locations: HashMap<PathBuf, usize> = HashMap::new();
for (i, candidate) in validated.iter().enumerate() {
let canonical = candidate
.wt
.path
.canonicalize()
.unwrap_or_else(|_| candidate.wt.path.clone());
current_locations.insert(canonical, i);
}
let mut blocked: HashSet<usize> = HashSet::new();
let mut skipped = 0;
for (i, candidate) in validated.iter().enumerate() {
let expected_path = &candidate.expected_path;
if !expected_path.exists() {
continue; }
let canonical_target = expected_path
.canonicalize()
.unwrap_or_else(|_| expected_path.clone());
if current_locations.contains_key(&canonical_target) {
continue;
}
let branch = candidate.branch();
if let Some((_, occupant_branch)) = repo.worktree_at_path(expected_path)? {
let occupant_name = occupant_branch.as_deref().unwrap_or("(detached)");
let msg = cformat!(
"Skipping <bold>{branch}</> (target is worktree for <bold>{occupant_name}</>)"
);
eprintln!("{}", warning_message(msg));
let hint = cformat!("Relocate or remove <underline>{occupant_name}</> first");
eprintln!("{}", hint_message(hint));
blocked.insert(i);
skipped += 1;
continue;
}
if clobber {
let timestamp_secs = worktrunk::utils::epoch_now() as i64;
let datetime = chrono::DateTime::from_timestamp(timestamp_secs, 0)
.unwrap_or_else(chrono::Utc::now);
let suffix = datetime.format("%Y%m%d-%H%M%S");
let backup_path = expected_path.with_file_name(format!(
"{}.bak-{suffix}",
expected_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
));
let src = format_path_for_display(expected_path);
let dest = format_path_for_display(&backup_path);
eprintln!(
"{}",
progress_message(cformat!("Backing up {src} → {dest}"))
);
std::fs::rename(expected_path, &backup_path).with_context(|| {
format!(
"Failed to backup {}",
format_path_for_display(expected_path)
)
})?;
} else {
let blocked_path = format_path_for_display(expected_path);
let msg = cformat!("Skipping <bold>{branch}</> (target blocked: {blocked_path})");
eprintln!("{}", warning_message(msg));
eprintln!(
"{}",
hint_message(cformat!(
"To backup blocking paths, use <underline>--clobber</>"
))
);
blocked.insert(i);
skipped += 1;
}
}
Ok(Self {
pending: validated,
current_locations,
blocked,
moved: HashSet::new(),
temp_relocated: Vec::new(),
temp_dir,
skipped,
relocated: 0,
})
}
pub fn execute(
&mut self,
repo_path: &Path,
default_branch: &str,
cwd: Option<&Path>,
) -> anyhow::Result<()> {
loop {
let mut made_progress = false;
for i in 0..self.pending.len() {
if self.moved.contains(&i) || self.blocked.contains(&i) {
continue;
}
match self.is_target_empty(i) {
Some(true) => {
self.move_worktree(i, repo_path, default_branch, cwd)?;
made_progress = true;
}
Some(false) => {
}
None => {
let branch = self.pending[i].branch();
let blocked_path = format_path_for_display(&self.pending[i].expected_path);
let msg = cformat!(
"Skipping <bold>{branch}</> (target occupied: {blocked_path})"
);
eprintln!("{}", warning_message(msg));
self.blocked.insert(i);
self.skipped += 1;
}
}
}
if made_progress {
continue;
}
if !self.break_cycle()? {
break; }
}
self.finalize_temp_relocations()?;
if self.temp_dir.exists() {
let _ = std::fs::remove_dir(&self.temp_dir);
}
Ok(())
}
fn is_target_empty(&self, idx: usize) -> Option<bool> {
let expected = &self.pending[idx].expected_path;
if !expected.exists() {
return Some(true);
}
let canonical = expected.canonicalize().unwrap_or_else(|_| expected.clone());
self.current_locations
.get(&canonical)
.map(|occupant_idx| self.moved.contains(occupant_idx))
}
fn move_worktree(
&mut self,
idx: usize,
repo_path: &Path,
default_branch: &str,
cwd: Option<&Path>,
) -> anyhow::Result<()> {
let branch = self.pending[idx].branch().to_string();
let is_main = self.pending[idx].is_main;
let src_path = self.pending[idx].wt.path.clone();
let dest_path = self.pending[idx].expected_path.clone();
let src_display = format_path_for_display(&src_path);
let dest_display = format_path_for_display(&dest_path);
if is_main {
self.move_main_worktree(idx, repo_path, default_branch)?;
} else {
Cmd::new("git")
.args(["worktree", "move"])
.arg(src_path.to_string_lossy())
.arg(dest_path.to_string_lossy())
.context(&branch)
.run()
.context("Failed to move worktree")?;
}
let msg = cformat!("Relocated <bold>{branch}</>: {src_display} → {dest_display}");
eprintln!("{}", success_message(msg));
if let Some(cwd_path) = cwd
&& cwd_path.starts_with(&src_path)
{
let relative = cwd_path.strip_prefix(&src_path).unwrap_or(Path::new(""));
crate::output::change_directory(dest_path.join(relative))?;
}
self.moved.insert(idx);
self.relocated += 1;
Ok(())
}
fn move_main_worktree(
&mut self,
idx: usize,
repo_path: &Path,
default_branch: &str,
) -> anyhow::Result<()> {
let candidate = &self.pending[idx];
let branch = candidate.branch();
let msg = cformat!("Switching main worktree to <bold>{default_branch}</>...");
eprintln!("{}", progress_message(msg));
Cmd::new("git")
.args(["checkout", default_branch])
.current_dir(repo_path)
.context("main")
.run()
.context("Failed to checkout default branch")?;
let add_result = Cmd::new("git")
.args(["worktree", "add"])
.arg(candidate.expected_path.to_string_lossy())
.arg(branch)
.context(branch)
.run();
if let Err(e) = add_result {
let rollback_msg = cformat!("Worktree creation failed, restoring <bold>{branch}</>...");
eprintln!("{}", warning_message(rollback_msg));
let _ = Cmd::new("git")
.args(["checkout", branch])
.current_dir(repo_path)
.context("main")
.run();
return Err(e).context("Failed to create worktree for main relocation");
}
Ok(())
}
fn break_cycle(&mut self) -> anyhow::Result<bool> {
let cycle_idx = (0..self.pending.len())
.filter(|&i| !self.moved.contains(&i) && !self.blocked.contains(&i))
.find(|&i| !self.pending[i].is_main);
let cycle_idx = cycle_idx.or_else(|| {
(0..self.pending.len())
.find(|&i| !self.moved.contains(&i) && !self.blocked.contains(&i))
});
let Some(i) = cycle_idx else {
return Ok(false);
};
let candidate = &self.pending[i];
let branch = candidate.branch();
std::fs::create_dir_all(&self.temp_dir)?;
let safe_branch = worktrunk::path::sanitize_for_filename(branch);
let temp_path = self.temp_dir.join(&safe_branch);
let msg = cformat!("Moving <bold>{branch}</> to temporary location...");
eprintln!("{}", progress_message(msg));
Cmd::new("git")
.args(["worktree", "move"])
.arg(candidate.wt.path.to_string_lossy())
.arg(temp_path.to_string_lossy())
.context(branch)
.run()
.context("Failed to move worktree to temp")?;
let old_canonical = candidate
.wt
.path
.canonicalize()
.unwrap_or_else(|_| candidate.wt.path.clone());
self.current_locations.remove(&old_canonical);
self.temp_relocated.push(TempRelocation {
index: i,
temp_path,
original_path: candidate.wt.path.clone(),
});
self.moved.insert(i);
Ok(true)
}
fn finalize_temp_relocations(&mut self) -> anyhow::Result<()> {
for temp in std::mem::take(&mut self.temp_relocated) {
let candidate = &self.pending[temp.index];
let branch = candidate.branch();
let src_display = format_path_for_display(&temp.original_path);
let dest_display = format_path_for_display(&candidate.expected_path);
Cmd::new("git")
.args(["worktree", "move"])
.arg(temp.temp_path.to_string_lossy())
.arg(candidate.expected_path.to_string_lossy())
.context(branch)
.run()
.context("Failed to move worktree from temp to final location")?;
let msg = cformat!("Relocated <bold>{branch}</>: {src_display} → {dest_display}");
eprintln!("{}", success_message(msg));
self.relocated += 1;
}
Ok(())
}
}
pub fn show_dry_run_preview(candidates: &[RelocationCandidate]) {
eprintln!(
"{}",
info_message(format!(
"{} worktree{} would be relocated:",
candidates.len(),
if candidates.len() == 1 { "" } else { "s" }
))
);
let preview_lines: Vec<String> = candidates
.iter()
.map(|c| {
let branch = c.branch();
let src_display = format_path_for_display(&c.wt.path);
let dest_display = format_path_for_display(&c.expected_path);
cformat!("<bold>{branch}</>: {src_display} → {dest_display}")
})
.collect();
eprintln!("{}", format_with_gutter(&preview_lines.join("\n"), None));
}
pub fn show_summary(relocated: usize, skipped: usize) {
if relocated > 0 || skipped > 0 {
eprintln!();
let plural = |n: usize| if n == 1 { "worktree" } else { "worktrees" };
if skipped == 0 {
let msg = format!("Relocated {relocated} {}", plural(relocated));
eprintln!("{}", success_message(msg));
} else {
let msg = format!(
"Relocated {relocated} {}, skipped {skipped} {}",
plural(relocated),
plural(skipped)
);
eprintln!("{}", info_message(msg));
}
}
}
pub fn show_no_relocations_needed(template_errors: usize) {
if template_errors == 0 {
eprintln!("{}", info_message("All worktrees are at expected paths"));
} else {
eprintln!(
"{}",
info_message(format!(
"No relocations performed; {} skipped due to template error{}",
template_errors,
if template_errors == 1 { "" } else { "s" }
))
);
}
}
pub fn show_all_skipped(skipped: usize) {
if skipped > 0 {
eprintln!();
eprintln!(
"{}",
info_message(format!(
"Skipped {skipped} worktree{}",
if skipped == 1 { "" } else { "s" }
))
);
}
}