use std::fs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use anyhow::Context;
use color_print::cformat;
use path_slash::PathExt as _;
use worktrunk::copy::{copy_dir_recursive, copy_leaf};
use worktrunk::git::Repository;
use worktrunk::progress::Progress;
use worktrunk::styling::{eprintln, hint_message, info_message, success_message, warning_message};
use super::shared::list_and_filter_ignored_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, None, true, &Progress::disabled())?;
fs::remove_dir_all(src).context(format!("removing source directory {}", src.display()))?;
} else {
copy_leaf(src, dest, None, 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 resolve_target_branch(branch: Option<&str>, repo: &Repository) -> anyhow::Result<String> {
use worktrunk::git::GitError;
if let Some(b) = branch {
return Ok(b.to_string());
}
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()),
}
.into()
})
}
}
fn check_leftover_staging(staging_path: &Path) -> anyhow::Result<()> {
if !staging_path.exists() {
return Ok(());
}
let display = staging_path.to_slash_lossy();
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"))
}
fn print_promote_announcement(is_restoring: bool, default_branch: Option<&str>) {
if is_restoring {
eprintln!("{}", info_message("Restoring main worktree"));
return;
}
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"
))
);
}
}
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 = resolve_target_branch(branch, &repo)?;
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);
check_leftover_staging(&staging_path)?;
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);
print_promote_announcement(is_restoring, default_branch.as_deref());
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)
}
#[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");
}
}