use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::git::repository::WorkingTree;
use crate::git::{IntegrationReason, Repository};
use crate::shell_exec::Cmd;
use crate::utils::epoch_now;
const FSMONITOR_STOP_TIMEOUT: Duration = Duration::from_secs(2);
#[cfg(unix)]
const FSMONITOR_LSOF_TIMEOUT: Duration = Duration::from_secs(2);
pub fn stop_fsmonitor_daemon(worktree: &WorkingTree) {
let _ = Cmd::new("git")
.args(["fsmonitor--daemon", "stop"])
.current_dir(worktree.path())
.context(crate::git::repository::path_to_logging_context(
worktree.path(),
))
.timeout(FSMONITOR_STOP_TIMEOUT)
.run();
let socket = match worktree.git_dir() {
Ok(git_dir) => git_dir.join("fsmonitor--daemon.ipc"),
Err(e) => {
log::debug!("fsmonitor: could not resolve git dir, skipping force-kill: {e}");
return;
}
};
force_kill_fsmonitor_via_socket(&socket);
}
#[cfg(unix)]
fn force_kill_fsmonitor_via_socket(socket: &Path) {
if !socket.exists() {
return;
}
let output = match Cmd::new("lsof")
.arg("-t")
.arg("--")
.arg(socket.to_string_lossy().into_owned())
.timeout(FSMONITOR_LSOF_TIMEOUT)
.run()
{
Ok(output) => output,
Err(e) => {
log::debug!("fsmonitor: lsof failed, cannot force-kill: {e}");
return;
}
};
let pids: Vec<u32> = String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.trim().parse::<u32>().ok())
.collect();
super::fsmonitor::escalate_terminate(
&super::fsmonitor::NixSignaller,
&pids,
super::fsmonitor::REAP_KILL_DEADLINE,
);
}
#[cfg(not(unix))]
fn force_kill_fsmonitor_via_socket(_socket: &Path) {}
#[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,
snapshot: &crate::git::RefSnapshot,
worktree_path: &Path,
options: RemoveOptions,
) -> anyhow::Result<RemovalOutput> {
stop_fsmonitor_daemon(&repo.worktree_at(worktree_path));
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,
snapshot,
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,
snapshot: &crate::git::RefSnapshot,
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(snapshot, 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));
}
#[test]
fn test_fsmonitor_socket_resolves_to_linked_worktree_git_dir() {
use crate::git::Repository;
let tmp = tempfile::tempdir().unwrap();
let gitconfig = tmp.path().join("gitconfig");
std::fs::write(
&gitconfig,
"[init]\n\tdefaultBranch = main\n[user]\n\tname = t\n\temail = t@t\n",
)
.unwrap();
let git = |dir: &Path| {
Cmd::new("git")
.current_dir(dir)
.env("GIT_CONFIG_GLOBAL", &gitconfig)
.env("GIT_CONFIG_SYSTEM", "/dev/null")
};
let main = tmp.path().join("repo");
std::fs::create_dir(&main).unwrap();
git(&main).args(["init", "-b", "main"]).run().unwrap();
git(&main)
.args(["commit", "--allow-empty", "-m", "init"])
.run()
.unwrap();
let linked = tmp.path().join("repo.feature");
git(&main)
.args(["worktree", "add", linked.to_str().unwrap(), "-b", "feature"])
.run()
.unwrap();
assert!(linked.join(".git").is_file());
let repo = Repository::at(&main).unwrap();
let wt = repo.worktree_at(&linked);
let git_dir = wt.git_dir().unwrap();
assert!(
git_dir.ends_with("worktrees/repo.feature"),
"expected per-worktree git dir, got {}",
git_dir.display()
);
let socket = git_dir.join("fsmonitor--daemon.ipc");
assert!(
!socket.starts_with(&linked),
"socket must resolve via the .git file, not <worktree>/.git: {}",
socket.display()
);
assert!(!socket.exists());
stop_fsmonitor_daemon(&wt);
}
#[test]
fn test_fsmonitor_stop_unresolvable_git_dir_is_noop() {
use crate::git::Repository;
let tmp = tempfile::tempdir().unwrap();
let gitconfig = tmp.path().join("gitconfig");
std::fs::write(
&gitconfig,
"[init]\n\tdefaultBranch = main\n[user]\n\tname = t\n\temail = t@t\n",
)
.unwrap();
let main = tmp.path().join("repo");
std::fs::create_dir(&main).unwrap();
Cmd::new("git")
.current_dir(&main)
.env("GIT_CONFIG_GLOBAL", &gitconfig)
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.args(["init", "-b", "main"])
.run()
.unwrap();
let repo = Repository::at(&main).unwrap();
let not_a_worktree = tmp.path().join("nope");
std::fs::create_dir(¬_a_worktree).unwrap();
let wt = repo.worktree_at(¬_a_worktree);
assert!(wt.git_dir().is_err(), "precondition: git dir unresolvable");
stop_fsmonitor_daemon(&wt);
}
#[cfg(unix)]
#[test]
fn test_fsmonitor_force_kill_unheld_socket_is_noop() {
use crate::git::Repository;
let tmp = tempfile::tempdir().unwrap();
let gitconfig = tmp.path().join("gitconfig");
std::fs::write(
&gitconfig,
"[init]\n\tdefaultBranch = main\n[user]\n\tname = t\n\temail = t@t\n",
)
.unwrap();
let main = tmp.path().join("repo");
std::fs::create_dir(&main).unwrap();
Cmd::new("git")
.current_dir(&main)
.env("GIT_CONFIG_GLOBAL", &gitconfig)
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.args(["init", "-b", "main"])
.run()
.unwrap();
let repo = Repository::at(&main).unwrap();
let wt = repo.worktree_at(&main);
let socket = wt.git_dir().unwrap().join("fsmonitor--daemon.ipc");
std::fs::write(&socket, b"").unwrap();
stop_fsmonitor_daemon(&wt);
assert!(socket.exists(), "no-op path must not delete the socket");
}
}