use anyhow::Context;
use std::fs;
use std::path::{Path, PathBuf};
#[cfg(windows)]
use std::process::Command;
use std::process::Stdio;
use worktrunk::git::{HookType, Repository};
use worktrunk::path::{format_path_for_display, sanitize_for_filename};
use worktrunk::utils::epoch_now;
use crate::commands::hook_filter::HookSource;
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::EnumString, strum::Display, strum::EnumIter)]
#[strum(serialize_all = "kebab-case")]
pub enum InternalOp {
Remove,
TrashSweep,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookLog {
Hook {
source: HookSource,
hook_type: HookType,
name: String,
},
Internal(InternalOp),
}
impl HookLog {
pub fn hook(source: HookSource, hook_type: HookType, name: impl Into<String>) -> Self {
Self::Hook {
source,
hook_type,
name: name.into(),
}
}
pub fn internal(op: InternalOp) -> Self {
Self::Internal(op)
}
pub fn path(&self, log_dir: &Path, branch: &str) -> PathBuf {
let branch_dir = log_dir.join(sanitize_for_filename(branch));
match self {
HookLog::Hook {
source,
hook_type,
name,
} => branch_dir
.join(source.to_string())
.join(hook_type.to_string())
.join(format!("{}.log", sanitize_for_filename(name))),
HookLog::Internal(op) => branch_dir.join("internal").join(format!("{op}.log")),
}
}
}
fn posix_command_separator(command: &str) -> &'static str {
if command.ends_with('\n') || command.ends_with(';') {
""
} else {
";"
}
}
fn create_detach_log(
repo: &Repository,
branch: &str,
hook_log: &HookLog,
) -> anyhow::Result<(PathBuf, fs::File)> {
let log_dir = repo.wt_logs_dir();
let log_path = hook_log.path(&log_dir, branch);
let parent = log_path
.parent()
.expect("HookLog::path always includes a parent");
fs::create_dir_all(parent).with_context(|| {
format!(
"Failed to create log directory {}",
format_path_for_display(parent)
)
})?;
let log_file = fs::File::create(&log_path).with_context(|| {
format!(
"Failed to create log file {}",
format_path_for_display(&log_path)
)
})?;
Ok((log_path, log_file))
}
pub fn spawn_detached(
repo: &Repository,
worktree_path: &Path,
command: &str,
branch: &str,
hook_log: &HookLog,
context_json: Option<&str>,
) -> anyhow::Result<std::path::PathBuf> {
let (log_path, log_file) = create_detach_log(repo, branch, hook_log)?;
log::debug!(
"$ {} (detached, logging to {})",
command,
log_path.file_name().unwrap_or_default().to_string_lossy()
);
#[cfg(unix)]
{
let low_priority = matches!(hook_log, HookLog::Internal(_));
spawn_detached_unix(worktree_path, command, log_file, context_json, low_priority)?;
}
#[cfg(windows)]
{
spawn_detached_windows(worktree_path, command, log_file, context_json)?;
}
Ok(log_path)
}
#[cfg(unix)]
fn spawn_detached_unix(
worktree_path: &Path,
command: &str,
log_file: fs::File,
context_json: Option<&str>,
low_priority: bool,
) -> anyhow::Result<()> {
use std::os::unix::process::CommandExt;
let full_command = match context_json {
Some(json) => {
format!(
"printf '%s' {} | {{ {}{} }}",
shell_escape::escape(json.into()),
command,
posix_command_separator(command)
)
}
None => command.to_string(),
};
let shell_cmd = format!(
"{{ {}{} }} &",
full_command,
posix_command_separator(&full_command)
);
let mut cmd = worktrunk::priority::command("sh", low_priority);
cmd.arg("-c")
.arg(&shell_cmd)
.current_dir(worktree_path)
.stdin(Stdio::null())
.stdout(Stdio::from(
log_file
.try_clone()
.context("Failed to clone log file handle")?,
))
.stderr(Stdio::from(log_file))
.process_group(0); worktrunk::shell_exec::scrub_directive_env_vars(&mut cmd);
let mut child = cmd.spawn().context("Failed to spawn detached process")?;
child
.wait()
.context("Failed to wait for detachment shell")?;
Ok(())
}
#[cfg(windows)]
fn spawn_detached_windows(
worktree_path: &Path,
command: &str,
log_file: fs::File,
context_json: Option<&str>,
) -> anyhow::Result<()> {
use std::os::windows::process::CommandExt;
use worktrunk::shell_exec::ShellConfig;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
let shell = ShellConfig::get()?;
let mut cmd = if shell.is_posix() {
let full_command = match context_json {
Some(json) => {
format!(
"printf '%s' {} | {{ {}{} }}",
shell_escape::escape(json.into()),
command,
posix_command_separator(command)
)
}
None => command.to_string(),
};
shell.command(&full_command)
} else {
let full_command = match context_json {
Some(json) => {
let escaped_json = json.replace('\'', "''");
format!("'{}' | & {{ {} }}", escaped_json, command)
}
None => command.to_string(),
};
shell.command(&full_command)
};
cmd.current_dir(worktree_path)
.stdin(Stdio::null())
.stdout(Stdio::from(
log_file
.try_clone()
.context("Failed to clone log file handle")?,
))
.stderr(Stdio::from(log_file))
.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS);
worktrunk::shell_exec::scrub_directive_env_vars(&mut cmd);
cmd.spawn().context("Failed to spawn detached process")?;
Ok(())
}
pub fn spawn_detached_exec(
repo: &Repository,
worktree_path: &Path,
program: &Path,
args: &[&str],
branch: &str,
hook_log: &HookLog,
stdin_bytes: &[u8],
) -> anyhow::Result<std::path::PathBuf> {
let (log_path, log_file) = create_detach_log(repo, branch, hook_log)?;
log::debug!(
"$ {} {} (detached, logging to {})",
program.display(),
args.join(" "),
log_path.file_name().unwrap_or_default().to_string_lossy()
);
#[cfg(unix)]
{
let low_priority = matches!(hook_log, HookLog::Internal(_));
spawn_detached_exec_unix(
worktree_path,
program,
args,
log_file,
stdin_bytes,
low_priority,
)?;
}
#[cfg(windows)]
{
spawn_detached_exec_windows(worktree_path, program, args, log_file, stdin_bytes)?;
}
Ok(log_path)
}
#[cfg(unix)]
fn spawn_detached_exec_unix(
worktree_path: &Path,
program: &Path,
args: &[&str],
log_file: fs::File,
stdin_bytes: &[u8],
low_priority: bool,
) -> anyhow::Result<()> {
use std::io::Write;
use std::os::unix::process::CommandExt;
let mut cmd = worktrunk::priority::command(program, low_priority);
cmd.args(args)
.current_dir(worktree_path)
.stdin(Stdio::piped())
.stdout(Stdio::from(
log_file
.try_clone()
.context("Failed to clone log file handle")?,
))
.stderr(Stdio::from(log_file))
.process_group(0);
worktrunk::shell_exec::scrub_directive_env_vars(&mut cmd);
let mut child = cmd.spawn().context("Failed to spawn detached process")?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(stdin_bytes);
}
Ok(())
}
#[cfg(windows)]
fn spawn_detached_exec_windows(
worktree_path: &Path,
program: &Path,
args: &[&str],
log_file: fs::File,
stdin_bytes: &[u8],
) -> anyhow::Result<()> {
use std::io::Write;
use std::os::windows::process::CommandExt;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
let mut cmd = Command::new(program);
cmd.args(args)
.current_dir(worktree_path)
.stdin(Stdio::piped())
.stdout(Stdio::from(
log_file
.try_clone()
.context("Failed to clone log file handle")?,
))
.stderr(Stdio::from(log_file))
.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS);
worktrunk::shell_exec::scrub_directive_env_vars(&mut cmd);
let mut child = cmd.spawn().context("Failed to spawn detached process")?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(stdin_bytes);
}
Ok(())
}
pub const TRASH_STALE_THRESHOLD_SECS: u64 = 24 * 60 * 60;
pub fn sweep_stale_trash(repo: &Repository) {
let trash_dir = repo.wt_trash_dir();
let stale = collect_stale_trash_entries(&trash_dir, epoch_now(), TRASH_STALE_THRESHOLD_SECS);
if stale.is_empty() {
return;
}
let escaped: Vec<String> = stale
.iter()
.map(|p| shell_escape::escape(p.to_string_lossy().as_ref().into()).into_owned())
.collect();
let command = format!("rm -rf -- {}", escaped.join(" "));
if let Err(e) = spawn_detached(
repo,
&repo.wt_dir(),
&command,
"wt",
&HookLog::internal(InternalOp::TrashSweep),
None,
) {
log::debug!("Failed to spawn stale trash sweep: {e}");
}
}
fn collect_stale_trash_entries(trash_dir: &Path, now: u64, threshold_secs: u64) -> Vec<PathBuf> {
let Ok(read_dir) = fs::read_dir(trash_dir) else {
return Vec::new();
};
read_dir
.filter_map(|entry| entry.ok())
.filter_map(|entry| {
let name = entry.file_name();
let timestamp = parse_trash_entry_timestamp(name.to_str()?)?;
let age = now.saturating_sub(timestamp);
(age >= threshold_secs).then(|| entry.path())
})
.collect()
}
fn parse_trash_entry_timestamp(name: &str) -> Option<u64> {
let (_, suffix) = name.rsplit_once('-')?;
suffix.parse::<u64>().ok()
}
pub fn build_remove_command_staged(
staged_path: &std::path::Path,
original_path: &std::path::Path,
changed_directory: bool,
) -> String {
use shell_escape::escape;
let staged_path_str = staged_path.to_string_lossy();
let staged_escaped = escape(staged_path_str.as_ref().into());
if changed_directory {
let original_path_str = original_path.to_string_lossy();
let original_escaped = escape(original_path_str.as_ref().into());
format!(
"sleep 1 && rmdir -- {} 2>/dev/null; rm -rf -- {}",
original_escaped, staged_escaped
)
} else {
format!("rm -rf -- {}", staged_escaped)
}
}
pub fn build_remove_command(
worktree_path: &std::path::Path,
branch_to_delete: Option<&str>,
force_worktree: bool,
changed_directory: bool,
) -> String {
use shell_escape::escape;
let worktree_path_str = worktree_path.to_string_lossy();
let worktree_escaped = escape(worktree_path_str.as_ref().into());
let stop_fsmonitor = format!(
"{{ git -C {} fsmonitor--daemon stop 2>/dev/null || true; }}",
worktree_escaped
);
let force_flag = if force_worktree { " --force" } else { "" };
let prefix = if changed_directory {
format!("sleep 1 && {} && ", stop_fsmonitor)
} else {
format!("{} && ", stop_fsmonitor)
};
match branch_to_delete {
Some(branch_name) => {
let branch_escaped = escape(branch_name.into());
format!(
"{}git worktree remove{} {} && git branch -D {}",
prefix, force_flag, worktree_escaped, branch_escaped
)
}
None => {
format!(
"{}git worktree remove{} {}",
prefix, force_flag, worktree_escaped
)
}
}
}
#[cfg(test)]
mod tests {
use insta::assert_snapshot;
use path_slash::PathExt as _;
use super::*;
#[test]
fn test_sanitize_for_filename() {
assert_snapshot!(
[
("path separator /", sanitize_for_filename("feature/branch")),
(r"path separator \", sanitize_for_filename(r"feature\branch")),
("colon", sanitize_for_filename("bug:123")),
("angle brackets", sanitize_for_filename("fix<angle>")),
("pipe", sanitize_for_filename("fix|pipe")),
("question mark", sanitize_for_filename("fix?question")),
("wildcard", sanitize_for_filename("fix*wildcard")),
("quotes", sanitize_for_filename(r#"fix"quotes""#)),
("multiple special", sanitize_for_filename(r#"a/b\c<d>e:f"g|h?i*j"#)),
("already safe", sanitize_for_filename("normal-branch")),
("underscore", sanitize_for_filename("branch_with_underscore")),
("reserved prefix CONSOLE", sanitize_for_filename("CONSOLE")),
("reserved prefix COM10", sanitize_for_filename("COM10")),
]
.into_iter()
.map(|(label, val)| format!("{label}: {val}"))
.collect::<Vec<_>>()
.join("\n"),
@r"
path separator /: feature-branch-30k
path separator \: feature-branch-k37
colon: bug-123-4xh
angle brackets: fix-angle-q9m
pipe: fix-pipe-68k
question mark: fix-question-ab6
wildcard: fix-wildcard-38y
quotes: fix-quotes-2xu
multiple special: a-b-c-d-e-f-g-h-i-j-obi
already safe: normal-branch
underscore: branch_with_underscore
reserved prefix CONSOLE: CONSOLE
reserved prefix COM10: COM10
"
);
for name in [
"CON", "con", "PRN", "AUX", "NUL", "COM0", "COM1", "com9", "LPT0", "LPT1", "lpt9",
] {
let result = sanitize_for_filename(name);
assert!(!result.is_empty() && result.len() > 3, "{name} -> {result}");
}
let a = sanitize_for_filename("feature/x");
let b = sanitize_for_filename("feature-x");
assert_ne!(a, b, "should not collide: {a} vs {b}");
}
#[test]
fn test_posix_command_separator() {
assert_eq!(posix_command_separator("echo hello\n"), "");
assert_eq!(posix_command_separator("echo hello;"), "");
assert_eq!(posix_command_separator("echo hello"), ";");
assert_eq!(posix_command_separator(""), ";");
assert_eq!(posix_command_separator("echo\nhello"), ";");
assert_eq!(posix_command_separator("echo; hello"), ";");
}
#[test]
fn test_build_remove_command() {
use std::path::PathBuf;
let path = PathBuf::from("/tmp/test-worktree");
assert_snapshot!(build_remove_command(&path, None, false, true), @"sleep 1 && { git -C /tmp/test-worktree fsmonitor--daemon stop 2>/dev/null || true; } && git worktree remove /tmp/test-worktree");
assert_snapshot!(build_remove_command(&path, Some("feature-branch"), false, true), @"sleep 1 && { git -C /tmp/test-worktree fsmonitor--daemon stop 2>/dev/null || true; } && git worktree remove /tmp/test-worktree && git branch -D feature-branch");
assert_snapshot!(build_remove_command(&path, None, false, false), @"{ git -C /tmp/test-worktree fsmonitor--daemon stop 2>/dev/null || true; } && git worktree remove /tmp/test-worktree");
assert_snapshot!(build_remove_command(&path, Some("feature-branch"), false, false), @"{ git -C /tmp/test-worktree fsmonitor--daemon stop 2>/dev/null || true; } && git worktree remove /tmp/test-worktree && git branch -D feature-branch");
assert_snapshot!(build_remove_command(&path, None, true, true), @"sleep 1 && { git -C /tmp/test-worktree fsmonitor--daemon stop 2>/dev/null || true; } && git worktree remove --force /tmp/test-worktree");
let special_path = PathBuf::from("/tmp/test worktree");
assert_snapshot!(build_remove_command(&special_path, Some("feature/branch"), false, true), @"sleep 1 && { git -C '/tmp/test worktree' fsmonitor--daemon stop 2>/dev/null || true; } && git worktree remove '/tmp/test worktree' && git branch -D feature/branch");
}
#[test]
fn test_build_remove_command_staged() {
let staged_path = PathBuf::from("/tmp/repo/.git/wt/trash/my-project.feature-1234567890");
let original_path = PathBuf::from("/tmp/my-project.feature");
assert_snapshot!(build_remove_command_staged(&staged_path, &original_path, true), @"sleep 1 && rmdir -- /tmp/my-project.feature 2>/dev/null; rm -rf -- /tmp/repo/.git/wt/trash/my-project.feature-1234567890");
assert_snapshot!(build_remove_command_staged(&staged_path, &original_path, false), @"rm -rf -- /tmp/repo/.git/wt/trash/my-project.feature-1234567890");
let special_path = PathBuf::from("/tmp/repo/.git/wt/trash/test worktree-123");
let special_original = PathBuf::from("/tmp/test worktree");
assert_snapshot!(build_remove_command_staged(&special_path, &special_original, true), @"sleep 1 && rmdir -- '/tmp/test worktree' 2>/dev/null; rm -rf -- '/tmp/repo/.git/wt/trash/test worktree-123'");
}
#[test]
fn test_hook_log_path() {
use worktrunk::git::HookType;
let log_dir = Path::new("/repo/.git/wt/logs");
let log = HookLog::hook(HookSource::User, HookType::PostStart, "server");
assert_snapshot!(
log.path(log_dir, "main").to_slash_lossy(),
@"/repo/.git/wt/logs/main/user/post-start/server.log"
);
assert_snapshot!(
log.path(log_dir, "feature/auth").to_slash_lossy(),
@"/repo/.git/wt/logs/feature-auth-j34/user/post-start/server.log"
);
let log = HookLog::hook(HookSource::Project, HookType::PreStart, "build");
assert_snapshot!(
log.path(log_dir, "main").to_slash_lossy(),
@"/repo/.git/wt/logs/main/project/pre-start/build.log"
);
assert_snapshot!(
HookLog::internal(InternalOp::Remove).path(log_dir, "main").to_slash_lossy(),
@"/repo/.git/wt/logs/main/internal/remove.log"
);
assert_snapshot!(
HookLog::internal(InternalOp::TrashSweep).path(log_dir, "wt").to_slash_lossy(),
@"/repo/.git/wt/logs/wt/internal/trash-sweep.log"
);
}
#[test]
fn test_parse_trash_entry_timestamp() {
assert_eq!(
parse_trash_entry_timestamp("feature-1700000000"),
Some(1700000000)
);
assert_eq!(
parse_trash_entry_timestamp("my-project.feature-branch-1700000000"),
Some(1700000000)
);
assert_eq!(parse_trash_entry_timestamp("no-timestamp"), None);
assert_eq!(parse_trash_entry_timestamp("notimestamp"), None);
assert_eq!(parse_trash_entry_timestamp(""), None);
}
#[test]
fn test_collect_stale_trash_entries() {
let trash = tempfile::tempdir().unwrap();
let now: u64 = 1_700_000_000;
let day = TRASH_STALE_THRESHOLD_SECS;
let stale = trash.path().join(format!("feature-old-{}", now - 2 * day));
fs::create_dir(&stale).unwrap();
let fresh = trash.path().join(format!("feature-new-{}", now - 3600));
fs::create_dir(&fresh).unwrap();
let boundary = trash.path().join(format!("feature-edge-{}", now - day));
fs::create_dir(&boundary).unwrap();
let foreign = trash.path().join("random-folder");
fs::create_dir(&foreign).unwrap();
let mut collected = collect_stale_trash_entries(trash.path(), now, day);
collected.sort();
let mut expected = vec![stale, boundary];
expected.sort();
assert_eq!(collected, expected);
assert!(
fresh.exists(),
"fresh entries must not appear in stale list"
);
assert!(foreign.exists(), "unparsable entries must be left alone");
}
#[test]
fn test_collect_stale_trash_entries_missing_dir() {
let missing = std::path::PathBuf::from("/nonexistent/wt/trash/path");
assert!(
collect_stale_trash_entries(&missing, 1_700_000_000, TRASH_STALE_THRESHOLD_SECS)
.is_empty()
);
}
}