use std::path::{Path, PathBuf};
use crate::git::{IntegrationReason, Repository};
use crate::utils::epoch_now;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BranchDeletionMode {
Keep,
#[default]
SafeDelete,
ForceDelete,
}
impl BranchDeletionMode {
pub fn from_flags(keep_branch: bool, force_delete: bool) -> Self {
if keep_branch {
Self::Keep
} else if force_delete {
Self::ForceDelete
} else {
Self::SafeDelete
}
}
pub fn should_keep(&self) -> bool {
matches!(self, Self::Keep)
}
pub fn is_force(&self) -> bool {
matches!(self, Self::ForceDelete)
}
}
pub enum BranchDeletionOutcome {
NotDeleted,
ForceDeleted,
Integrated(IntegrationReason),
}
pub struct BranchDeletionResult {
pub outcome: BranchDeletionOutcome,
pub integration_target: String,
}
#[derive(Debug, Clone, Default)]
pub struct RemoveOptions {
pub branch: Option<String>,
pub deletion_mode: BranchDeletionMode,
pub target_branch: Option<String>,
pub force_worktree: bool,
}
pub struct RemovalOutput {
pub branch_result: Option<anyhow::Result<BranchDeletionResult>>,
pub staged_path: Option<PathBuf>,
}
pub fn remove_worktree_with_cleanup(
repo: &Repository,
worktree_path: &Path,
options: RemoveOptions,
) -> anyhow::Result<RemovalOutput> {
let _ = repo
.worktree_at(worktree_path)
.run_command(&["fsmonitor--daemon", "stop"]);
let staged_path = stage_worktree_removal(repo, worktree_path);
if staged_path.is_none() {
repo.remove_worktree(worktree_path, options.force_worktree)?;
}
let branch_result = if let Some(branch) = options.branch.as_deref()
&& !options.deletion_mode.should_keep()
{
let target = options.target_branch.as_deref().unwrap_or("HEAD");
Some(delete_branch_if_safe(
repo,
branch,
target,
options.deletion_mode.is_force(),
))
} else {
None
};
Ok(RemovalOutput {
branch_result,
staged_path,
})
}
pub fn stage_worktree_removal(repo: &Repository, worktree_path: &Path) -> Option<PathBuf> {
let trash_dir = repo.wt_trash_dir();
let _ = std::fs::create_dir_all(&trash_dir);
let staged_path = generate_removing_path(&trash_dir, worktree_path);
if std::fs::rename(worktree_path, &staged_path).is_ok() {
if let Err(e) = repo.prune_worktrees() {
log::debug!("Failed to prune worktrees after rename: {e}");
}
Some(staged_path)
} else {
None
}
}
pub fn delete_branch_if_safe(
repo: &Repository,
branch_name: &str,
target: &str,
force_delete: bool,
) -> anyhow::Result<BranchDeletionResult> {
if force_delete {
repo.run_command(&["branch", "-D", branch_name])?;
return Ok(BranchDeletionResult {
outcome: BranchDeletionOutcome::ForceDeleted,
integration_target: target.to_string(),
});
}
let (effective_target, reason) = repo.integration_reason(branch_name, target)?;
let outcome = match reason {
Some(r) => {
repo.run_command(&["branch", "-D", branch_name])?;
BranchDeletionOutcome::Integrated(r)
}
None => BranchDeletionOutcome::NotDeleted,
};
Ok(BranchDeletionResult {
outcome,
integration_target: effective_target,
})
}
pub(crate) fn generate_removing_path(trash_dir: &Path, worktree_path: &Path) -> PathBuf {
let timestamp = epoch_now();
let name = worktree_path
.file_name()
.map(|n| n.to_string_lossy())
.unwrap_or_default();
trash_dir.join(format!("{}-{}", name, timestamp))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_branch_deletion_outcome_matching() {
let outcomes = [
(BranchDeletionOutcome::NotDeleted, false),
(BranchDeletionOutcome::ForceDeleted, true),
(
BranchDeletionOutcome::Integrated(IntegrationReason::SameCommit),
true,
),
];
for (outcome, expected_deleted) in outcomes {
let deleted = matches!(
outcome,
BranchDeletionOutcome::ForceDeleted | BranchDeletionOutcome::Integrated(_)
);
assert_eq!(deleted, expected_deleted);
}
}
#[test]
fn test_branch_deletion_mode_from_flags() {
assert_eq!(
BranchDeletionMode::from_flags(false, false),
BranchDeletionMode::SafeDelete
);
assert_eq!(
BranchDeletionMode::from_flags(false, true),
BranchDeletionMode::ForceDelete
);
assert_eq!(
BranchDeletionMode::from_flags(true, false),
BranchDeletionMode::Keep
);
assert_eq!(
BranchDeletionMode::from_flags(true, true),
BranchDeletionMode::Keep
);
}
#[test]
fn test_branch_deletion_mode_helpers() {
assert!(BranchDeletionMode::Keep.should_keep());
assert!(!BranchDeletionMode::SafeDelete.should_keep());
assert!(!BranchDeletionMode::ForceDelete.should_keep());
assert!(BranchDeletionMode::ForceDelete.is_force());
assert!(!BranchDeletionMode::SafeDelete.is_force());
assert!(!BranchDeletionMode::Keep.is_force());
}
#[test]
fn test_remove_options_default() {
let opts = RemoveOptions::default();
assert!(opts.branch.is_none());
assert_eq!(opts.deletion_mode, BranchDeletionMode::SafeDelete);
assert!(opts.target_branch.is_none());
assert!(!opts.force_worktree);
}
#[test]
fn test_generate_removing_path() {
let trash_dir = PathBuf::from("/some/path/.git/wt/trash");
let path = PathBuf::from("/foo/bar/feature-branch");
let removing_path = generate_removing_path(&trash_dir, &path);
let name = removing_path.file_name().unwrap().to_string_lossy();
assert!(name.starts_with("feature-branch-"));
assert!(removing_path.starts_with(&trash_dir));
}
}