use std::fs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::Context;
use color_print::cformat;
use crossbeam_channel as chan;
use ignore::gitignore::GitignoreBuilder;
use path_slash::PathExt as _;
use rayon::prelude::*;
use worktrunk::HookType;
use worktrunk::config::{CopyIgnoredConfig, UserConfig};
use worktrunk::copy::{copy_dir_recursive, copy_leaf};
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,
verbosity, warning_message,
};
use super::command_approval::approve_hooks;
use super::command_executor::FailureStrategy;
use super::commit::{CommitGenerator, CommitOptions, StageMode};
use super::context::CommandEnv;
use super::hooks::{
HookCommandSpec, prepare_background_hooks, run_hook_with_filter, spawn_hook_pipeline,
};
use super::repository_ext::{RemoveTarget, RepositoryCliExt};
use crate::output::handle_remove_output;
use worktrunk::git::BranchDeletionMode;
pub fn step_commit(
branch: Option<String>,
yes: bool,
verify: bool,
stage: Option<StageMode>,
show_prompt: bool,
) -> anyhow::Result<()> {
if show_prompt {
let repo = worktrunk::git::Repository::current()?;
let config = UserConfig::load().context("Failed to load config")?;
let project_id = repo.project_identifier().ok();
let commit_config = config.commit_generation(project_id.as_deref());
let prompt = crate::llm::build_commit_prompt(&commit_config)?;
println!("{}", prompt);
return Ok(());
}
let mut config = UserConfig::load().context("Failed to load config")?;
let _ = crate::output::prompt_commit_generation(&mut config);
let env = match branch {
Some(ref b) => CommandEnv::for_branch(config, b)?,
None => CommandEnv::for_action(config)?,
};
let ctx = env.context(yes);
let stage_mode = stage.unwrap_or(env.resolved().commit.stage());
let verify = if verify {
let approved = approve_hooks(&ctx, &[HookType::PreCommit, HookType::PostCommit])?;
if !approved {
eprintln!(
"{}",
info_message("Commands declined, committing without hooks",)
);
false
} else {
true
}
} else {
false };
let mut options = CommitOptions::new(&ctx);
options.verify = verify;
options.stage_mode = stage_mode;
options.show_no_squash_note = false;
options.warn_about_untracked = stage_mode == StageMode::All;
options.commit()
}
#[derive(Debug, Clone)]
pub enum SquashResult {
Squashed,
NoCommitsAhead(String),
AlreadySingleCommit,
NoNetChanges,
}
pub fn handle_squash(
target: Option<&str>,
yes: bool,
verify: bool,
stage: Option<StageMode>,
) -> anyhow::Result<SquashResult> {
let mut config = UserConfig::load().context("Failed to load config")?;
let _ = crate::output::prompt_commit_generation(&mut config);
let env = CommandEnv::for_action(config)?;
let repo = &env.repo;
let current_branch = env.require_branch("squash")?.to_string();
let ctx = env.context(yes);
let resolved = env.resolved();
let generator = CommitGenerator::new(&resolved.commit_generation);
let stage_mode = stage.unwrap_or(resolved.commit.stage());
let project_config = repo.load_project_config()?;
let user_hooks = ctx.config.hooks(ctx.project_id().as_deref());
let (user_cfg, proj_cfg) = super::hooks::lookup_hook_configs(
&user_hooks,
project_config.as_ref(),
HookType::PreCommit,
);
let any_hooks_exist = user_cfg.is_some() || proj_cfg.is_some();
let verify = if verify {
let approved = approve_hooks(&ctx, &[HookType::PreCommit, HookType::PostCommit])?;
if !approved {
eprintln!(
"{}",
info_message("Commands declined, squashing without hooks")
);
false
} else {
true
}
} else {
if any_hooks_exist {
eprintln!("{}", info_message("Skipping pre-commit hooks (--no-hooks)"));
}
false };
let integration_target = repo.require_target_ref(target)?;
match stage_mode {
StageMode::All => {
repo.warn_if_auto_staging_untracked()?;
repo.run_command(&["add", "-A"])
.context("Failed to stage changes")?;
}
StageMode::Tracked => {
repo.run_command(&["add", "-u"])
.context("Failed to stage tracked changes")?;
}
StageMode::None => {
}
}
if verify {
let extra_vars = [("target", integration_target.as_str())];
run_hook_with_filter(
&ctx,
HookCommandSpec {
user_config: user_cfg,
project_config: proj_cfg,
hook_type: HookType::PreCommit,
extra_vars: &extra_vars,
name_filters: &[],
display_path: crate::output::pre_hook_display_path(ctx.worktree_path),
},
FailureStrategy::FailFast,
)
.map_err(worktrunk::git::add_hook_skip_hint)?;
}
let merge_base = repo
.merge_base("HEAD", &integration_target)?
.context("Cannot squash: no common ancestor with target branch")?;
let commit_count = repo.count_commits(&merge_base, "HEAD")?;
let wt = repo.current_worktree();
let has_staged = wt.has_staged_changes()?;
if commit_count == 0 && !has_staged {
return Ok(SquashResult::NoCommitsAhead(integration_target));
}
if commit_count == 0 && has_staged {
generator.commit_staged_changes(&wt, true, true, stage_mode)?;
return Ok(SquashResult::Squashed);
}
if commit_count == 1 && !has_staged {
return Ok(SquashResult::AlreadySingleCommit);
}
let range = format!("{}..HEAD", merge_base);
let commit_text = if commit_count == 1 {
"commit"
} else {
"commits"
};
let total_stats = if has_staged {
repo.diff_stats_summary(&["diff", "--shortstat", &merge_base, "--cached"])
} else {
repo.diff_stats_summary(&["diff", "--shortstat", &range])
};
let with_changes = if has_staged {
match stage_mode {
StageMode::Tracked => " & tracked changes",
_ => " & working tree changes",
}
} else {
""
};
let parts = total_stats;
let squash_progress = if parts.is_empty() {
format!("Squashing {commit_count} {commit_text}{with_changes} into a single commit...")
} else {
let parts_str = parts.join(", ");
let paren_close = cformat!("<bright-black>)</>");
cformat!(
"Squashing {commit_count} {commit_text}{with_changes} into a single commit <bright-black>({parts_str}</>{paren_close}..."
)
};
eprintln!("{}", progress_message(squash_progress));
if has_staged {
let backup_message = format!("{} → {} (squash)", current_branch, integration_target);
let sha = wt.create_safety_backup(&backup_message)?;
eprintln!("{}", hint_message(format!("Backup created @ {sha}")));
}
let subjects = repo.commit_subjects(&range)?;
eprintln!(
"{}",
progress_message("Generating squash commit message...")
);
generator.emit_hint_if_needed();
let repo_root = wt.root()?;
let repo_name = repo_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("repo");
let commit_message = crate::llm::generate_squash_message(
&integration_target,
&merge_base,
&subjects,
¤t_branch,
repo_name,
&resolved.commit_generation,
)?;
let formatted_message = generator.format_message_for_display(&commit_message);
eprintln!("{}", format_with_gutter(&formatted_message, None));
repo.run_command(&["reset", "--soft", &merge_base])
.context("Failed to reset to merge base")?;
if !wt.has_staged_changes()? {
eprintln!(
"{}",
info_message(format!(
"No changes after squashing {commit_count} {commit_text}"
))
);
return Ok(SquashResult::NoNetChanges);
}
repo.run_command(&["commit", "-m", &commit_message])
.context("Failed to create squash commit")?;
let commit_hash = repo
.run_command(&["rev-parse", "--short", "HEAD"])?
.trim()
.to_string();
eprintln!(
"{}",
success_message(cformat!("Squashed @ <dim>{commit_hash}</>"))
);
if verify {
let extra_vars: Vec<(&str, &str)> = vec![("target", integration_target.as_str())];
for steps in prepare_background_hooks(&ctx, HookType::PostCommit, &extra_vars, None)? {
spawn_hook_pipeline(&ctx, steps)?;
}
}
Ok(SquashResult::Squashed)
}
pub fn step_show_squash_prompt(target: Option<&str>) -> anyhow::Result<()> {
let repo = Repository::current()?;
let config = UserConfig::load().context("Failed to load config")?;
let project_id = repo.project_identifier().ok();
let effective_config = config.commit_generation(project_id.as_deref());
let integration_target = repo.require_target_ref(target)?;
let wt = repo.current_worktree();
let current_branch = wt.branch()?.unwrap_or_else(|| "HEAD".to_string());
let merge_base = repo
.merge_base("HEAD", &integration_target)?
.context("Cannot generate squash message: no common ancestor with target branch")?;
let range = format!("{}..HEAD", merge_base);
let subjects = repo.commit_subjects(&range)?;
let repo_root = wt.root()?;
let repo_name = repo_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("repo");
let prompt = crate::llm::build_squash_prompt(
&integration_target,
&merge_base,
&subjects,
¤t_branch,
repo_name,
&effective_config,
)?;
println!("{}", prompt);
Ok(())
}
pub enum RebaseResult {
Rebased,
UpToDate(String),
}
pub fn handle_rebase(target: Option<&str>) -> anyhow::Result<RebaseResult> {
let repo = Repository::current()?;
let integration_target = repo.require_target_ref(target)?;
if repo.is_rebased_onto(&integration_target)? {
return Ok(RebaseResult::UpToDate(integration_target));
}
let merge_base = repo
.merge_base("HEAD", &integration_target)?
.context("Cannot rebase: no common ancestor with target branch")?;
let head_sha = repo.run_command(&["rev-parse", "HEAD"])?.trim().to_string();
let is_fast_forward = merge_base == head_sha;
if !is_fast_forward {
eprintln!(
"{}",
progress_message(cformat!("Rebasing onto <bold>{integration_target}</>..."))
);
}
let rebase_result = repo.run_command(&["rebase", &integration_target]);
if let Err(e) = rebase_result {
let is_rebasing = repo
.worktree_state()?
.is_some_and(|s| s.starts_with("REBASING"));
if is_rebasing {
let git_output = e.to_string();
return Err(worktrunk::git::GitError::RebaseConflict {
target_branch: integration_target,
git_output,
}
.into());
}
return Err(worktrunk::git::GitError::Other {
message: cformat!(
"Failed to rebase onto <bold>{}</>: {}",
integration_target,
e
),
}
.into());
}
if repo.worktree_state()?.is_some() {
return Err(worktrunk::git::GitError::RebaseConflict {
target_branch: integration_target,
git_output: String::new(),
}
.into());
}
let msg = if is_fast_forward {
cformat!("Fast-forwarded to <bold>{integration_target}</>")
} else {
cformat!("Rebased onto <bold>{integration_target}</>")
};
eprintln!("{}", success_message(msg));
Ok(RebaseResult::Rebased)
}
pub fn step_diff(target: Option<&str>, extra_args: &[String]) -> anyhow::Result<()> {
let repo = Repository::current()?;
let wt = repo.current_worktree();
let integration_target = repo.require_target_ref(target)?;
let merge_base = repo
.merge_base("HEAD", &integration_target)?
.context("No common ancestor with target branch")?;
let current_branch = wt.branch()?.unwrap_or_else(|| "HEAD".to_string());
let worktree_root = wt.root()?;
let real_index = wt.git_dir()?.join("index");
let temp_index = tempfile::NamedTempFile::new().context("Failed to create temporary index")?;
let temp_index_path = temp_index
.path()
.to_str()
.context("Temporary index path is not valid UTF-8")?;
std::fs::copy(&real_index, temp_index.path()).context("Failed to copy index file")?;
Cmd::new("git")
.args(["add", "--intent-to-add", "."])
.current_dir(&worktree_root)
.context(¤t_branch)
.env("GIT_INDEX_FILE", temp_index_path)
.run()
.context("Failed to register untracked files")?;
let mut diff_args = vec!["diff".to_string(), merge_base];
diff_args.extend_from_slice(extra_args);
Cmd::new("git")
.args(&diff_args)
.current_dir(&worktree_root)
.context(¤t_branch)
.env("GIT_INDEX_FILE", temp_index_path)
.stream()?;
Ok(())
}
const BUILTIN_COPY_IGNORED_EXCLUDES: &[&str] = &[
".bzr/",
".conductor/",
".entire/",
".hg/",
".jj/",
".pi/",
".pijul/",
".sl/",
".svn/",
".worktrees/",
];
fn default_copy_ignored_excludes() -> Vec<String> {
BUILTIN_COPY_IGNORED_EXCLUDES
.iter()
.map(|s| (*s).to_string())
.collect()
}
fn resolve_copy_ignored_config(repo: &Repository) -> anyhow::Result<CopyIgnoredConfig> {
let mut config = CopyIgnoredConfig {
exclude: default_copy_ignored_excludes(),
};
if let Some(project_config) = repo.load_project_config()?
&& let Some(project_copy_ignored) = project_config.copy_ignored()
{
config = config.merged_with(project_copy_ignored);
}
let user_config = UserConfig::load().context("Failed to load config")?;
let project_id = repo.project_identifier().ok();
config = config.merged_with(&user_config.copy_ignored(project_id.as_deref()));
Ok(config)
}
fn list_and_filter_ignored_entries(
worktree_path: &Path,
context: &str,
worktree_paths: &[PathBuf],
exclude_patterns: &[String],
) -> anyhow::Result<Vec<(PathBuf, bool)>> {
let ignored_entries = list_ignored_entries(worktree_path, context)?;
let include_path = worktree_path.join(".worktreeinclude");
let filtered: Vec<_> = if include_path.exists() {
let include_matcher = {
let mut builder = GitignoreBuilder::new(worktree_path);
if let Some(err) = builder.add(&include_path) {
return Err(worktrunk::git::GitError::WorktreeIncludeParseError {
error: err.to_string().replace('\\', "/"),
}
.into());
}
builder.build().context("Failed to build include matcher")?
};
ignored_entries
.into_iter()
.filter(|(path, is_dir)| include_matcher.matched(path, *is_dir).is_ignore())
.collect()
} else {
ignored_entries
};
let exclude_matcher = if exclude_patterns.is_empty() {
None
} else {
let mut builder = GitignoreBuilder::new(worktree_path);
for pattern in exclude_patterns {
builder.add_line(None, pattern).map_err(|error| {
anyhow::anyhow!(
"Invalid [step.copy-ignored].exclude pattern {:?}: {}",
pattern,
error
)
})?;
}
Some(
builder
.build()
.context("Failed to build copy-ignored exclude matcher")?,
)
};
Ok(filtered
.into_iter()
.filter(|(path, is_dir)| {
if let Some(ref matcher) = exclude_matcher {
let relative = path.strip_prefix(worktree_path).unwrap_or(path.as_path());
if matcher.matched(relative, *is_dir).is_ignore() {
return false;
}
}
if *is_dir
&& path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|name| {
BUILTIN_COPY_IGNORED_EXCLUDES
.iter()
.any(|pat| pat.trim_end_matches('/') == name)
})
{
return false;
}
!worktree_paths
.iter()
.any(|wt_path| wt_path != worktree_path && wt_path.starts_with(path))
})
.collect())
}
pub fn step_copy_ignored(
from: Option<&str>,
to: Option<&str>,
dry_run: bool,
force: bool,
) -> anyhow::Result<()> {
worktrunk::priority::lower_current_process();
let repo = Repository::current()?;
let copy_ignored_config = resolve_copy_ignored_config(&repo)?;
let (source_path, source_context) = match from {
Some(branch) => {
let path = repo.worktree_for_branch(branch)?.ok_or_else(|| {
worktrunk::git::GitError::WorktreeNotFound {
branch: branch.to_string(),
}
})?;
(path, branch.to_string())
}
None => {
let path = repo.primary_worktree()?.ok_or_else(|| {
anyhow::anyhow!(
"No primary worktree found (bare repo with no default branch worktree)"
)
})?;
let context = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
(path, context)
}
};
let dest_path = match to {
Some(branch) => repo.worktree_for_branch(branch)?.ok_or_else(|| {
worktrunk::git::GitError::WorktreeNotFound {
branch: branch.to_string(),
}
})?,
None => repo.current_worktree().root()?,
};
if source_path == dest_path {
eprintln!(
"{}",
info_message("Source and destination are the same worktree")
);
return Ok(());
}
let worktree_paths: Vec<PathBuf> = repo
.list_worktrees()?
.into_iter()
.map(|wt| wt.path)
.collect();
let entries_to_copy = list_and_filter_ignored_entries(
&source_path,
&source_context,
&worktree_paths,
©_ignored_config.exclude,
)?;
if entries_to_copy.is_empty() {
eprintln!("{}", info_message("No matching files to copy"));
return Ok(());
}
let verbose = verbosity();
if verbose >= 1 || dry_run {
let items: Vec<String> = entries_to_copy
.iter()
.map(|(src_entry, is_dir)| {
let relative = src_entry
.strip_prefix(&source_path)
.unwrap_or(src_entry.as_path());
let entry_type = if *is_dir { "dir" } else { "file" };
format!("{} ({})", format_path_for_display(relative), entry_type)
})
.collect();
let entry_word = if items.len() == 1 { "entry" } else { "entries" };
let verb = if dry_run { "Would copy" } else { "Copying" };
eprintln!(
"{}",
info_message(format!(
"{verb} {} {}:\n{}",
items.len(),
entry_word,
format_with_gutter(&items.join("\n"), None)
))
);
if dry_run {
return Ok(());
}
}
let mut copied_count = 0usize;
for (src_entry, is_dir) in &entries_to_copy {
let relative = src_entry
.strip_prefix(&source_path)
.expect("git ls-files path under worktree");
let dest_entry = dest_path.join(relative);
if *is_dir {
copied_count +=
copy_dir_recursive(src_entry, &dest_entry, force).with_context(|| {
format!("copying directory {}", format_path_for_display(relative))
})?;
} else {
if let Some(parent) = dest_entry.parent() {
fs::create_dir_all(parent).with_context(|| {
format!(
"creating directory for {}",
format_path_for_display(relative)
)
})?;
}
if copy_leaf(src_entry, &dest_entry, force)? {
copied_count += 1;
}
}
}
let file_word = if copied_count == 1 { "file" } else { "files" };
eprintln!(
"{}",
success_message(format!("Copied {copied_count} {file_word}"))
);
Ok(())
}
fn list_ignored_entries(
worktree_path: &Path,
context: &str,
) -> anyhow::Result<Vec<(std::path::PathBuf, bool)>> {
let output = Cmd::new("git")
.args([
"ls-files",
"--ignored",
"--exclude-standard",
"-o",
"--directory",
])
.current_dir(worktree_path)
.context(context)
.run()
.context("Failed to run git ls-files")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git ls-files failed: {}", stderr.trim());
}
let entries = String::from_utf8_lossy(&output.stdout)
.lines()
.map(|line| {
let is_dir = line.ends_with('/');
let path = worktree_path.join(line.trim_end_matches('/'));
(path, is_dir)
})
.collect();
Ok(entries)
}
fn move_entry(src: &Path, dest: &Path, is_dir: bool) -> anyhow::Result<()> {
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.context(format!("creating parent directory for {}", dest.display()))?;
}
match fs::rename(src, dest) {
Ok(()) => Ok(()),
Err(e) if e.kind() == ErrorKind::CrossesDevices => copy_and_remove(src, dest, is_dir),
Err(e) => Err(anyhow::Error::from(e).context(format!(
"moving {} to {}",
src.display(),
dest.display()
))),
}
}
fn copy_and_remove(src: &Path, dest: &Path, is_dir: bool) -> anyhow::Result<()> {
if is_dir {
copy_dir_recursive(src, dest, true)?;
fs::remove_dir_all(src).context(format!("removing source directory {}", src.display()))?;
} else {
copy_leaf(src, dest, true)?;
fs::remove_file(src).context(format!("removing source file {}", src.display()))?;
}
Ok(())
}
const PROMOTE_STAGING_DIR: &str = "staging/promote";
fn stage_ignored(
repo: &Repository,
path_a: &Path,
entries_a: &[(PathBuf, bool)],
path_b: &Path,
entries_b: &[(PathBuf, bool)],
) -> anyhow::Result<(PathBuf, usize)> {
let staging_dir = repo.wt_dir().join(PROMOTE_STAGING_DIR);
fs::create_dir_all(&staging_dir).context("creating promote staging directory")?;
let staging_a = staging_dir.join("a");
let staging_b = staging_dir.join("b");
let mut count = 0;
for (src_entry, is_dir) in entries_a {
let relative = src_entry
.strip_prefix(path_a)
.context("entry not under worktree A")?;
let staging_entry = staging_a.join(relative);
if fs::symlink_metadata(src_entry).is_ok() {
move_entry(src_entry, &staging_entry, *is_dir)
.context(format!("staging {}", relative.display()))?;
count += 1;
}
}
for (src_entry, is_dir) in entries_b {
let relative = src_entry
.strip_prefix(path_b)
.context("entry not under worktree B")?;
let staging_entry = staging_b.join(relative);
if fs::symlink_metadata(src_entry).is_ok() {
move_entry(src_entry, &staging_entry, *is_dir)
.context(format!("staging {}", relative.display()))?;
count += 1;
}
}
if count == 0 && staging_dir.exists() {
let _ = fs::remove_dir_all(&staging_dir);
}
Ok((staging_dir, count))
}
fn distribute_staged(
staging_dir: &Path,
path_a: &Path,
entries_a: &[(PathBuf, bool)],
path_b: &Path,
entries_b: &[(PathBuf, bool)],
) -> anyhow::Result<usize> {
let staging_a = staging_dir.join("a");
let staging_b = staging_dir.join("b");
let mut count = 0;
for (src_entry, is_dir) in entries_b {
let relative = src_entry
.strip_prefix(path_b)
.context("entry not under worktree B")?;
let staging_entry = staging_b.join(relative);
let dest_entry = path_a.join(relative);
if fs::symlink_metadata(&staging_entry).is_ok() {
move_entry(&staging_entry, &dest_entry, *is_dir)
.context(format!("distributing {}", relative.display()))?;
count += 1;
}
}
for (src_entry, is_dir) in entries_a {
let relative = src_entry
.strip_prefix(path_a)
.context("entry not under worktree A")?;
let staging_entry = staging_a.join(relative);
let dest_entry = path_b.join(relative);
if fs::symlink_metadata(&staging_entry).is_ok() {
move_entry(&staging_entry, &dest_entry, *is_dir)
.context(format!("distributing {}", relative.display()))?;
count += 1;
}
}
let _ = fs::remove_dir_all(staging_dir);
Ok(count)
}
pub enum PromoteResult {
Promoted,
AlreadyInMain(String),
}
fn exchange_branches(
main_wt: &worktrunk::git::WorkingTree<'_>,
main_branch: &str,
target_wt: &worktrunk::git::WorkingTree<'_>,
target_branch: &str,
) -> anyhow::Result<()> {
let steps: &[(&worktrunk::git::WorkingTree<'_>, &[&str], &str)] = &[
(target_wt, &["switch", "--detach"], "detach target"),
(main_wt, &["switch", "--detach"], "detach main"),
(main_wt, &["switch", target_branch], "switch main"),
(target_wt, &["switch", main_branch], "switch target"),
];
for (wt, args, label) in steps {
if let Err(e) = wt.run_command(args) {
let _ = main_wt.run_command(&["switch", main_branch]);
let _ = target_wt.run_command(&["switch", target_branch]);
return Err(e.context(format!("branch exchange failed at: {label}")));
}
}
Ok(())
}
pub fn handle_promote(branch: Option<&str>) -> anyhow::Result<PromoteResult> {
use worktrunk::git::GitError;
let repo = Repository::current()?;
let worktrees = repo.list_worktrees()?;
if worktrees.is_empty() {
anyhow::bail!("No worktrees found");
}
if repo.is_bare()? {
anyhow::bail!("wt step promote is not supported in bare repositories");
}
let main_wt = &worktrees[0];
let main_path = &main_wt.path;
let main_branch = main_wt
.branch
.clone()
.ok_or_else(|| GitError::DetachedHead {
action: Some("promote".into()),
})?;
let target_branch = match branch {
Some(b) => b.to_string(),
None => {
let current_wt = repo.current_worktree();
if !current_wt.is_linked()? {
repo.default_branch()
.ok_or_else(|| anyhow::anyhow!("Could not determine default branch"))?
} else {
current_wt.branch()?.ok_or_else(|| GitError::DetachedHead {
action: Some("promote".into()),
})?
}
}
};
if target_branch == main_branch {
return Ok(PromoteResult::AlreadyInMain(target_branch));
}
let target_wt = worktrees
.iter()
.skip(1) .find(|wt| wt.branch.as_deref() == Some(&target_branch))
.ok_or_else(|| GitError::WorktreeNotFound {
branch: target_branch.clone(),
})?;
let target_path = &target_wt.path;
let staging_path = repo.wt_dir().join(PROMOTE_STAGING_DIR);
if staging_path.exists() {
let display = staging_path.to_slash_lossy();
return Err(anyhow::anyhow!(
"Files may need manual recovery from: {display}\n\
Remove it to retry: rm -rf \"{display}\""
)
.context("Found leftover staging directory from an interrupted promote"));
}
let main_working_tree = repo.worktree_at(main_path);
let target_working_tree = repo.worktree_at(target_path);
main_working_tree.ensure_clean("promote", Some(&main_branch), false)?;
target_working_tree.ensure_clean("promote", Some(&target_branch), false)?;
let default_branch = repo.default_branch();
let is_restoring = default_branch.as_ref() == Some(&target_branch);
if is_restoring {
eprintln!("{}", info_message("Restoring main worktree"));
} else {
eprintln!(
"{}",
warning_message("Promoting creates mismatched worktree state (shown as âš‘ in wt list)",)
);
if let Some(default) = &default_branch {
eprintln!(
"{}",
hint_message(cformat!(
"Run <underline>wt step promote {default}</> to restore canonical locations"
))
);
}
}
let worktree_paths: Vec<PathBuf> = worktrees.iter().map(|wt| wt.path.clone()).collect();
let no_excludes: &[String] = &[];
let main_entries =
list_and_filter_ignored_entries(main_path, &main_branch, &worktree_paths, no_excludes)?;
let target_entries =
list_and_filter_ignored_entries(target_path, &target_branch, &worktree_paths, no_excludes)?;
let staged = if !main_entries.is_empty() || !target_entries.is_empty() {
let (dir, count) = stage_ignored(
&repo,
main_path,
&main_entries,
target_path,
&target_entries,
)
.context(format!(
"Failed to stage ignored files. Already-staged files may be recoverable from: {}",
staging_path.to_slash_lossy()
))?;
if count > 0 { Some((dir, count)) } else { None }
} else {
None
};
exchange_branches(
&main_working_tree,
&main_branch,
&target_working_tree,
&target_branch,
)?;
let swapped = if let Some((ref staging_dir, _)) = staged {
distribute_staged(
staging_dir,
main_path,
&main_entries,
target_path,
&target_entries,
)
.context(format!(
"Failed to distribute staged files. Staged files may be recoverable from: {}",
staging_dir.display()
))?
} else {
0
};
eprintln!(
"{}",
success_message(cformat!(
"Promoted: main worktree now has <bold>{target_branch}</>; {} now has <bold>{main_branch}</>",
worktrunk::path::format_path_for_display(target_path)
))
);
if swapped > 0 {
let path_word = if swapped == 1 { "path" } else { "paths" };
eprintln!(
"{}",
success_message(format!("Swapped {swapped} gitignored {path_word}"))
);
}
Ok(PromoteResult::Promoted)
}
pub fn step_prune(
dry_run: bool,
yes: bool,
min_age: &str,
foreground: bool,
format: crate::cli::SwitchFormat,
) -> anyhow::Result<()> {
let min_age_duration =
humantime::parse_duration(min_age).context("Invalid --min-age duration")?;
let repo = Repository::current()?;
let config = UserConfig::load()?;
let integration_target = match repo.integration_target() {
Some(target) => target,
None => {
anyhow::bail!("cannot determine default branch");
}
};
let worktrees = repo.list_worktrees()?;
let current_root = repo.current_worktree().root()?.to_path_buf();
let current_root = dunce::canonicalize(¤t_root).unwrap_or(current_root);
let now_secs = worktrunk::utils::epoch_now();
let default_branch = repo.default_branch();
struct Candidate {
check_idx: usize,
branch: Option<String>,
label: String,
path: Option<PathBuf>,
kind: CandidateKind,
}
enum CandidateKind {
Current,
Other,
BranchOnly,
}
impl CandidateKind {
fn as_str(&self) -> &'static str {
match self {
CandidateKind::Current => "current",
CandidateKind::Other => "worktree",
CandidateKind::BranchOnly => "branch_only",
}
}
}
fn prune_summary(candidates: &[Candidate]) -> String {
let mut worktree_with_branch = 0usize;
let mut detached_worktree = 0usize;
let mut branch_only = 0usize;
for c in candidates {
match (&c.kind, &c.branch) {
(CandidateKind::BranchOnly, _) => branch_only += 1,
(CandidateKind::Current | CandidateKind::Other, Some(_)) => {
worktree_with_branch += 1;
}
(CandidateKind::Current | CandidateKind::Other, None) => {
detached_worktree += 1;
}
}
}
let mut parts = Vec::new();
if worktree_with_branch > 0 {
let noun = if worktree_with_branch == 1 {
"worktree & branch"
} else {
"worktrees & branches"
};
parts.push(format!("{worktree_with_branch} {noun}"));
}
if detached_worktree > 0 {
let noun = if detached_worktree == 1 {
"worktree"
} else {
"worktrees"
};
parts.push(format!("{detached_worktree} {noun}"));
}
if branch_only > 0 {
let noun = if branch_only == 1 {
"branch"
} else {
"branches"
};
parts.push(format!("{branch_only} {noun}"));
}
parts.join(", ")
}
let run_hooks = if dry_run {
false } else {
let env = CommandEnv::for_action_branchless()?;
let ctx = env.context(yes);
let approved = approve_hooks(
&ctx,
&[
HookType::PreRemove,
HookType::PostRemove,
HookType::PostSwitch,
],
)?;
if !approved {
eprintln!("{}", info_message("Commands declined, continuing removal"));
}
approved
};
let mut removed: Vec<Candidate> = Vec::new();
let mut deferred_current: Option<Candidate> = None;
let mut skipped_young: Vec<String> = Vec::new();
let mut seen_branches: std::collections::HashSet<String> = std::collections::HashSet::new();
fn try_remove(
candidate: &Candidate,
repo: &Repository,
config: &UserConfig,
foreground: bool,
run_hooks: bool,
worktrees: &[WorktreeInfo],
) -> anyhow::Result<bool> {
let target = match candidate.kind {
CandidateKind::Current => RemoveTarget::Current,
CandidateKind::BranchOnly => RemoveTarget::Branch(
candidate
.branch
.as_ref()
.context("BranchOnly candidate missing branch")?,
),
CandidateKind::Other => match &candidate.branch {
Some(branch) => RemoveTarget::Branch(branch),
None => RemoveTarget::Path(
candidate
.path
.as_ref()
.context("detached candidate missing path")?,
),
},
};
let plan = match repo.prepare_worktree_removal(
target,
BranchDeletionMode::SafeDelete,
false,
config,
None,
Some(worktrees),
) {
Ok(plan) => plan,
Err(_) => {
return Ok(false);
}
};
handle_remove_output(&plan, foreground, run_hooks, true, true)?;
Ok(true)
}
enum CheckSource {
Prunable { branch: String },
Linked { wt_idx: usize },
Orphan,
}
struct CheckItem {
integration_ref: String,
source: CheckSource,
}
let mut check_items: Vec<CheckItem> = Vec::new();
for (idx, wt) in worktrees.iter().enumerate() {
if let Some(branch) = &wt.branch {
seen_branches.insert(branch.clone());
}
if wt.locked.is_some() {
continue;
}
if let Some(branch) = &wt.branch
&& default_branch.as_deref() == Some(branch.as_str())
{
continue;
}
if wt.is_prunable() {
if let Some(branch) = &wt.branch {
check_items.push(CheckItem {
integration_ref: branch.clone(),
source: CheckSource::Prunable {
branch: branch.clone(),
},
});
}
continue;
}
let wt_tree = repo.worktree_at(&wt.path);
if !wt_tree.is_linked()? {
continue;
}
let integration_ref = match &wt.branch {
Some(b) if !wt.detached => b.clone(),
_ => wt.head.clone(),
};
check_items.push(CheckItem {
integration_ref,
source: CheckSource::Linked { wt_idx: idx },
});
}
for branch in repo.all_branches()? {
if seen_branches.contains(&branch) {
continue;
}
if default_branch.as_deref() == Some(branch.as_str()) {
continue;
}
check_items.push(CheckItem {
integration_ref: branch,
source: CheckSource::Orphan,
});
}
let (tx, rx) = chan::unbounded();
let integration_refs: Vec<String> = check_items
.iter()
.map(|item| item.integration_ref.clone())
.collect();
let repo_clone = repo.clone();
let target = integration_target.clone();
std::thread::spawn(move || {
integration_refs
.into_par_iter()
.enumerate()
.for_each(|(idx, ref_name)| {
let result = repo_clone.integration_reason(&ref_name, &target);
let _ = tx.send((idx, result));
});
});
struct DryRunInfo {
reason_desc: String,
effective_target: String,
suffix: &'static str,
}
let mut dry_run_info: Vec<(Candidate, DryRunInfo)> = Vec::new();
for (idx, result) in rx {
let (effective_target, reason) = result?;
let Some(reason) = reason else {
continue;
};
let item = &check_items[idx];
if let CheckSource::Linked { wt_idx } = &item.source {
let wt = &worktrees[*wt_idx];
let label = wt
.branch
.clone()
.unwrap_or_else(|| format!("(detached {})", &wt.head[..7.min(wt.head.len())]));
if min_age_duration > Duration::ZERO {
let wt_tree = repo.worktree_at(&wt.path);
let git_dir = wt_tree.git_dir()?;
let metadata = fs::metadata(&git_dir).context("Failed to read worktree git dir")?;
let created = metadata.created().or_else(|_| {
fs::metadata(git_dir.join("commondir")).and_then(|m| m.modified())
});
if let Ok(created) = created
&& let Ok(created_epoch) = created.duration_since(std::time::UNIX_EPOCH)
{
let age = Duration::from_secs(now_secs.saturating_sub(created_epoch.as_secs()));
if age < min_age_duration {
if !dry_run {
eprintln!(
"{}",
info_message(format!("Skipped {label} (younger than {min_age})"))
);
}
skipped_young.push(label);
continue;
}
}
}
let wt_path = dunce::canonicalize(&wt.path).unwrap_or(wt.path.clone());
let is_current = wt_path == current_root;
let candidate = Candidate {
check_idx: idx,
branch: if wt.detached { None } else { wt.branch.clone() },
label,
path: Some(wt.path.clone()),
kind: if is_current {
CandidateKind::Current
} else {
CandidateKind::Other
},
};
if dry_run {
let info = DryRunInfo {
reason_desc: reason.description().to_string(),
effective_target,
suffix: "",
};
dry_run_info.push((candidate, info));
} else if is_current {
deferred_current = Some(candidate);
} else if try_remove(
&candidate, &repo, &config, foreground, run_hooks, &worktrees,
)? {
removed.push(candidate);
}
continue;
}
let (branch, suffix) = match &item.source {
CheckSource::Prunable { branch } => (branch, " (stale)"),
CheckSource::Orphan => (&item.integration_ref, " (branch only)"),
CheckSource::Linked { .. } => unreachable!(),
};
if matches!(&item.source, CheckSource::Orphan) && min_age_duration > Duration::ZERO {
let ref_name = format!("refs/heads/{branch}");
if let Ok(stdout) = repo.run_command(&["reflog", "show", "--format=%ct", &ref_name])
&& let Some(created_epoch) = stdout
.trim()
.lines()
.last()
.and_then(|s| s.parse::<u64>().ok())
{
let age = Duration::from_secs(now_secs.saturating_sub(created_epoch));
if age < min_age_duration {
if !dry_run {
eprintln!(
"{}",
info_message(format!("Skipped {branch} (younger than {min_age})"))
);
}
skipped_young.push(branch.clone());
continue;
}
}
}
let candidate = Candidate {
check_idx: idx,
label: branch.clone(),
branch: Some(branch.clone()),
path: None,
kind: CandidateKind::BranchOnly,
};
if dry_run {
let info = DryRunInfo {
reason_desc: reason.description().to_string(),
effective_target,
suffix,
};
dry_run_info.push((candidate, info));
} else if try_remove(
&candidate, &repo, &config, foreground, run_hooks, &worktrees,
)? {
removed.push(candidate);
}
}
if dry_run {
dry_run_info.sort_by_key(|(c, _)| c.check_idx);
if format == crate::cli::SwitchFormat::Json {
let items: Vec<serde_json::Value> = dry_run_info
.iter()
.map(|(c, info)| {
serde_json::json!({
"branch": c.branch,
"path": c.path,
"kind": c.kind.as_str(),
"reason": info.reason_desc,
"target": info.effective_target,
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&items)?);
return Ok(());
}
let mut dry_candidates = Vec::new();
for (candidate, info) in dry_run_info {
eprintln!(
"{}",
info_message(cformat!(
"<bold>{}</>{} — {} {}",
candidate.label,
info.suffix,
info.reason_desc,
info.effective_target
))
);
dry_candidates.push(candidate);
}
skipped_young.sort();
if !skipped_young.is_empty() {
let names = skipped_young.join(", ");
eprintln!(
"{}",
info_message(format!("Skipped {names} (younger than {min_age})"))
);
}
if dry_candidates.is_empty() {
if skipped_young.is_empty() {
eprintln!("{}", info_message("No merged worktrees to remove"));
}
return Ok(());
}
eprintln!(
"{}",
hint_message(format!(
"{} would be removed (dry run)",
prune_summary(&dry_candidates)
))
);
return Ok(());
}
if let Some(current) = deferred_current
&& try_remove(¤t, &repo, &config, foreground, run_hooks, &worktrees)?
{
removed.push(current);
}
if format == crate::cli::SwitchFormat::Json {
let items: Vec<serde_json::Value> = removed
.iter()
.map(|c| {
serde_json::json!({
"branch": c.branch,
"path": c.path,
"kind": c.kind.as_str(),
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&items)?);
} else if removed.is_empty() {
if skipped_young.is_empty() {
eprintln!("{}", info_message("No merged worktrees to remove"));
}
} else {
eprintln!(
"{}",
success_message(format!("Pruned {}", prune_summary(&removed)))
);
}
Ok(())
}
pub fn step_relocate(
branches: Vec<String>,
dry_run: bool,
commit: bool,
clobber: bool,
) -> anyhow::Result<()> {
use super::relocate::{
GatherResult, RelocationExecutor, ValidationResult, gather_candidates, show_all_skipped,
show_dry_run_preview, show_no_relocations_needed, show_summary, validate_candidates,
};
let repo = Repository::current()?;
let config = UserConfig::load()?;
let default_branch = repo.default_branch().unwrap_or_default();
if default_branch.is_empty() {
anyhow::bail!(
"Cannot determine default branch; set with: wt config state default-branch set main"
);
}
let repo_path = repo.repo_path()?.to_path_buf();
let GatherResult {
candidates,
template_errors,
} = gather_candidates(&repo, &config, &branches)?;
if candidates.is_empty() {
show_no_relocations_needed(template_errors);
return Ok(());
}
if dry_run {
show_dry_run_preview(&candidates);
return Ok(());
}
let ValidationResult { validated, skipped } =
validate_candidates(&repo, &config, candidates, commit, &repo_path)?;
if validated.is_empty() {
show_all_skipped(skipped);
return Ok(());
}
let mut executor = RelocationExecutor::new(&repo, validated, clobber)?;
let cwd = std::env::current_dir().ok();
executor.execute(&repo_path, &default_branch, cwd.as_deref())?;
let total_skipped = skipped + executor.skipped;
show_summary(executor.relocated, total_skipped);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_move_entry_file() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("source.txt");
let dest = tmp.path().join("subdir/dest.txt");
fs::write(&src, "content").unwrap();
move_entry(&src, &dest, false).unwrap();
assert!(!src.exists());
assert_eq!(fs::read_to_string(&dest).unwrap(), "content");
}
#[test]
fn test_move_entry_directory() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("srcdir");
let dest = tmp.path().join("nested/destdir");
fs::create_dir_all(src.join("inner")).unwrap();
fs::write(src.join("inner/file.txt"), "nested").unwrap();
fs::write(src.join("root.txt"), "root").unwrap();
move_entry(&src, &dest, true).unwrap();
assert!(!src.exists());
assert_eq!(
fs::read_to_string(dest.join("inner/file.txt")).unwrap(),
"nested"
);
assert_eq!(fs::read_to_string(dest.join("root.txt")).unwrap(), "root");
}
#[test]
fn test_copy_and_remove_file() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("source.txt");
let dest = tmp.path().join("dest.txt");
fs::write(&src, "content").unwrap();
copy_and_remove(&src, &dest, false).unwrap();
assert!(!src.exists());
assert_eq!(fs::read_to_string(&dest).unwrap(), "content");
}
#[test]
fn test_copy_and_remove_directory() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("srcdir");
let dest = tmp.path().join("destdir");
fs::create_dir_all(src.join("sub")).unwrap();
fs::write(src.join("sub/file.txt"), "nested").unwrap();
fs::write(src.join("root.txt"), "root").unwrap();
copy_and_remove(&src, &dest, true).unwrap();
assert!(!src.exists());
assert_eq!(
fs::read_to_string(dest.join("sub/file.txt")).unwrap(),
"nested"
);
assert_eq!(fs::read_to_string(dest.join("root.txt")).unwrap(), "root");
}
}